diff --git a/app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/player/PlayerOsd.kt b/app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/player/PlayerOsd.kt index f2012d0..3ee2112 100644 --- a/app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/player/PlayerOsd.kt +++ b/app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/player/PlayerOsd.kt @@ -54,7 +54,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType * The player's controls ("OSD") as drawn in the mockup's player screen; sizes are the mockup's px / 2. * Everything here is drawn over the video, which on TVs sits on its own hardware plane: the UI layer is only * redrawn when something in it changes (a second of the clock, a focus move), so the shades cost nothing while - * the picture plays. Through the GPU path (SDR on the Xiaomi TV) every video frame redraws them, see APP_PLAN.md. + * the picture plays. Through the GPU path (SDR on the Xiaomi TV) every video frame redraws them, see docs/ui-performance.md. */ internal object OsdColors { diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index bd10132..d3c8402 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -1,6 +1,6 @@ -// SleepyOwl for the Linux desktop (LINUX_PLAN.md): windows, desktop screens, later the mpv player and system integration. +// SleepyOwl for the Linux desktop (docs/desktop.md): windows, desktop screens, the mpv player and system integration. // Run: ./gradlew :desktop:run, or tools/linux/desktop.sh without Gradle around the process. -// Package: ./gradlew :desktop:packageDeb → desktop/build/deb/sleepyowl__amd64.deb (LINUX_PLAN.md L10). +// Package: ./gradlew :desktop:packageDeb → desktop/build/deb/sleepyowl__amd64.deb. import java.nio.file.Files import java.nio.file.Path import java.nio.file.attribute.PosixFilePermissions diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..412f52a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,34 @@ +# SleepyOwl + +SleepyOwl — open-source клиент Jellyfin на Kotlin: сначала для Android TV, теперь есть и версия для Linux (ПК). +Лицензия GPL-3.0-or-later. Название и логотип свои, без бренда Jellyfin. Пакет `ru.stenfredd.sleepyowl` +(прежнее рабочее название — Movies, `ru.stenfredd.movies`). + +**Цель:** воспроизводить всё, что можно, без серверного транскода и с правильным HDR и Dolby Vision. Другие клиенты +на тестовом ТВ спотыкались на разных файлах: транскодировали, давали тусклый HDR или зависали. + +- **Android TV:** гибрид media3/ExoPlayer (основной: аппаратный MediaCodec, HDR10/HDR10+/HLG/DV, passthrough) + + libVLC (запасной, программный ffmpeg). Движок выбирается автоматически по «весу потерь»; при падении или зависании + сессия переходит на другой движок с той же позиции. Серверный транскод — последний вариант. На тестовой медиатеке + (12 671 файл: MKV/AVI/MP4, DivX, H.264 Hi10P, HEVC HDR10, DV, AV1, MPEG-2, AC3/DTS/TrueHD, ASS/PGS) транскод + не понадобился ни разу. +- **Linux (ПК):** движок libmpv, плеер в окне, во весь экран и плавающим окном поверх остальных, MPRIS, пакет .deb. + HDR на ПК сводится к SDR. +- **Общий код** — модули Kotlin Multiplatform (android + desktop): `core:*`, `playback:api`, `playback:core`, + `ui:shared` (Compose Multiplatform). DI — Koin. +- **Стек:** Kotlin, Jetpack Compose / Compose Multiplatform, Navigation 3, Coil 3, `jellyfin-sdk-kotlin` 1.9 + (сервер Jellyfin 12.x), media3, libVLC, ass-media (libass), libmpv (JNA), OkHttp, DataStore. +- **Тестовое устройство:** Xiaomi TV S Pro Mini LED 75 (MediaTek MT9689, Android 14, только 32-bit, 2,4 ГБ RAM, + слабый Mali GPU). APK содержит `armeabi-v7a` и `arm64-v8a`. + +## Документы + +| Файл | О чём | +|---|---| +| [development.md](development.md) | Сборка, инструменты в `tools/`, личные настройки в `local/` | +| [playback.md](playback.md) | **Главный документ по воспроизведению:** архитектура, выбор движка, все найденные проблемы media3/libVLC/ТВ MediaTek и их решения, сеть, память, как тестировали | +| [tv-app.md](tv-app.md) | Приложение для Android TV: архитектура интерфейса, решения, ловушки фокуса и пульта, особенности сервера, по экранам | +| [ui-performance.md](ui-performance.md) | Производительность интерфейса на слабом GPU ТВ: что сколько стоит, как мерить, какие приёмы использовать | +| [launcher-feed.md](launcher-feed.md) | Контракт «Продолжить просмотр» для своей оболочки главного экрана ТВ | +| [desktop.md](desktop.md) | Версия для Linux: решения, архитектура (mpv в Compose, окна X11, MPRIS), находки и ловушки, тестовый стенд | +| [research/](research/) | Исходные исследования до кода: Jellyfin API и клиенты, media3, libVLC против libmpv, тестовый ТВ, профиль медиатеки, дампы ТВ | diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..73767a3 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,609 @@ +# SleepyOwl для Linux (ПК) + +Версия SleepyOwl для компьютера с Linux: та же медиатека Jellyfin, что на Android TV, и плеер на libmpv, который умеет висеть маленьким окном поверх всех окон. Здесь записано, как она устроена, почему решения именно такие, какие ловушки встретились по дороге (с замерами) и как её тестировать. + +Ядро у ТВ- и ПК-версии общее. Как устроено воспроизведение на ТВ (Exo + VLC, выбор движка, детали протокола Jellyfin), описано в [`playback.md`](playback.md); здесь — только то, что относится к ПК. + +Код ПК: + +- `desktop/` — приложение для ПК: окна, экраны, плеер, интеграция с системой, сборка пакета; +- `playback/engine-mpv/` — движок libmpv (JVM, JNA); +- `ui/shared/` — общий с ТВ слой интерфейса (ViewModel'и, тема, компоненты); +- `core/*`, `playback/api`, `playback/core` — общее ядро (Kotlin Multiplatform); +- `tools/linux/` — запуск без Gradle, тестовый стенд, сквозная проверка, служебные скрипты. + +--- + +## 0. Коротко + +1. **Главный сценарий — смотреть «в фоне».** Выбрал серию — видео идёт в мини-окне без рамки поверх всех окон: его можно таскать и растягивать (с пропорциями видео), в нём доступны все функции плеера, управление прячется, когда мышь уходит. Окно не забирает фокус у других программ, медиаклавиши работают через MPRIS. +2. **Один движок — libmpv** (системная `libmpv2` 0.37), через свою привязку на JNA. mpv играет всё, что встретилось на ТВ-медиатеке, без транскода, своих экстракторов и прокси. +3. **Видео в Compose — копией кадра:** mpv рисует в своём невидимом контексте EGL, кадр через PBO попадает в картинку Skia, OSD рисуется поверх в том же окне. Общий с Skiko контекст GL (без копии) не дал выигрыша в мини-окне и стоил рефлексии и чёрных HDR-кадров. +4. **HDR не цель.** На Cinnamon/X11 HDR-вывода нет; HDR10, HDR10+, HLG и Dolby Vision (P8.x) mpv переводит в SDR (`tone-mapping=auto`). +5. **Окна — X11** (на Wayland — через XWayland): «поверх всех», положение окна и смена рамки на лету доступны только там. +6. **Общий код — KMP + Koin** на обеих платформах; ТВ-версия переехала на них без потери скорости. +7. **Установка — .deb** со своей урезанной JRE 21 внутри, собирается `./gradlew :desktop:packageDeb`. + +## 1. Исходные условия + +- **Тестовый ПК:** Linux Mint 22.3, Cinnamon на X11 (оконный менеджер Muffin), NVIDIA RTX 4090 (драйвер 580), процессор класса i7-13700KF (24 потока, без встроенной графики), мониторы 3440×1440 175 Гц (основной) и 1080×1920 144 Гц (портретный). В репозиториях Mint — libmpv 0.37. +- **Другие ПК — «по возможности»:** на Intel/AMD аппаратное декодирование идёт через VAAPI, mpv выбирает его сам (`hwdec=auto-safe`). +- **Версии:** JDK 21, Kotlin/JVM + Compose Multiplatform 1.12.2 (Skiko 0.150), JNA 5.18.1, Koin 4.2.2, dbus-java 5.2.2, AGP 9.4.1 (Android-часть KMP-модулей). + +## 2. Главные решения + +| Что | Решение | Почему | +|---|---|---| +| Язык и интерфейс | Kotlin + Compose Multiplatform (Desktop) | тот же Compose, что на ТВ: переиспользуются ядро, ViewModel'и, тема, компоненты | +| Общий код | модули Kotlin Multiplatform с целями `android` и `jvm("desktop")` | одно ядро на две платформы; при двух JVM-целях `commonMain` компилируется как обычный JVM-код (§5.6) | +| DI | Koin на обеих платформах вместо Hilt | Hilt работает только на Android и не работает в KMP-модулях | +| Общий интерфейс | модуль `ui:shared` (KMP + Compose Multiplatform) | ViewModel'и, навигационные ключи, модели карточек, подписи, тема, шрифты, иконки и визуальные компоненты — общие; раскладки и ввод (пульт против мыши) — свои у каждой платформы | +| Воспроизведение | один движок — libmpv | mpv (ffmpeg + NVDEC/VAAPI) играет всё; связка Exo+VLC, исправленные экстракторы и локальный прокси ТВ на ПК не нужны | +| Привязка к libmpv | своя, через JNA (~300–400 строк), без сторонних обёрток | нужно немного: client API и render API | +| Видео в окне | mpv рисует в текстуру, Compose показывает её картинкой, OSD — поверх **в том же окне** | только так окно плеера — одно целое: таскается, растягивается и висит поверх вместе с управлением. HDR при этом невозможен — он и не нужен | +| HDR / DV | в SDR, тонмаппинг mpv | на Cinnamon/X11 HDR-вывода нет вообще; render API mpv умеет только старый рендерер `gpu` | +| Окна | X11, на Wayland — через XWayland | «поверх всех» и положение окна на Wayland приложению недоступны | +| Плеер | отдельное окно, три режима: мини поверх всех, обычное окно, весь экран | медиатекой можно пользоваться, пока идёт видео; режимы меняются без остановки видео | +| Установка | .deb (свой JRE внутри), Flatpak — потом | на одном ПК .deb проще всего; libmpv — системная | + +**Сколько кода переиспользовано** (оценка на старте): ядро (`core:*`, `playback:api`, `playback:core`, ~3,2 тыс. строк) и ViewModel'и с моделями карточек, подписями и темой (~3,2 тыс.) — целиком; экраны ТВ (~8,5 тыс.) — визуальные компоненты частично, раскладки и ввод под мышь написаны заново; логика плеера ТВ (`PlayerViewModel`, `PlaybackSession`, пропуск заставок, «следующая серия») — общая, OSD для мыши и маленького окна — новый; движки Exo/VLC (~2,4 тыс.) на ПК не нужны. Новое: движок mpv (~1 тыс. строк), окна и плеер ПК (~2–3 тыс.), экраны ПК. + +## 3. Как это выглядит и ведёт себя + +### 3.1 Окна и режимы плеера + +- **Окно «Медиатека»** — обычное окно с рамкой, 1280×800 при запуске, минимум 800×500. Боковое меню и экраны как на ТВ. +- **Плеер — отдельное окно.** У каждого окна своё место и размер; закрытие плеера не сбрасывает место в медиатеке. +- **Три режима плеера**, переключаются в любой момент, видео не останавливается и не перезапускается: + 1. **Мини поверх всех** — без рамки, `_NET_WM_STATE_ABOVE`. Таскается за любое место видео, размер меняется за края (8 px) и углы (20 px), пропорции видео сохраняются, минимум 320×180. + 2. **Обычное окно** — с рамкой, не поверх. + 3. **Во весь экран** — на мониторе, где сейчас окно. +- **Переключение:** `T` — мини ↔ обычное окно (из полного экрана — всегда в мини); `F` — весь экран, `Esc` — выход из него; двойной щелчок по видео — мини → окно → весь экран → назад; кнопки режима в OSD; меню правой кнопки. Из полного экрана `F`, `Esc` и двойной щелчок возвращают в тот оконный режим, из которого пришли. Выход из полного экрана сочетанием клавиш самого WM читается обратно в режим. +- **Первый запуск плеера** — мини 640×360 в правом нижнем углу основного монитора с отступом 24 px. Мини-окно при открытии и при смене видео подгоняется под пропорции видео с сохранением ширины и правого нижнего угла (640×266 для 2,39:1). Обычное окно по умолчанию — 1280×720 по центру. +- **Что запоминается** (`desktop_player.preferences_pb`): последний режим (включая весь экран: закрыл во весь экран — следующий плеер откроется так же), место и размер мини и обычного окна (клиентская область в пикселях экрана), «на всех рабочих столах». Место записывается через 0,8 с после перемещения или изменения размера, при смене режима и при закрытии; перед выходом приложение ждёт последнюю запись. Место, которое не видно ни на одном мониторе (центр вне экранов или видно меньше половины), заменяется местом по умолчанию. +- **Настройка «Открывать плеер»** (как в прошлый раз / мини / окно / весь экран) действует только на новое окно плеера; уже открытое (следующая серия, «Смотреть» другое) остаётся в своём режиме. +- **«На всех рабочих столах»** (`_NET_WM_STATE_STICKY`) для мини-окна — в настройках («Компьютер») и пунктом с галочкой в меню правой кнопки мини-окна; по умолчанию выключено. +- **Кнопки:** × закрывает плеер; «К медиатеке» поднимает окно медиатеки (из полного экрана сначала выходит). В OSD значок «картинка в картинке» переводит в мини, значок «окно» — из мини в обычное. «К сериалу» / «Страница фильма» открывает страницу в окне медиатеки и поднимает его. +- **Конец фильма** (или последней серии) закрывает окно плеера, конец серии — следующая серия в том же окне. + +### 3.2 Сценарий «смотреть в фоне» + +1. В медиатеке выбрал серию → «Смотреть» (или «Продолжить» на главной). +2. Окно плеера открывается в последнем режиме (по умолчанию мини) и **один раз** получает фокус. +3. Щелчок в другую программу — мини-окно остаётся поверх и не забирает фокус обратно. +4. Навёл мышь на мини-окно — появилось управление, увёл — скрылось. +5. Следующая серия начинается в том же окне и тоже не забирает фокус (окно не пересоздаётся, `toFront()` — только при открытии). +6. Медиаклавиши работают, когда фокус в другом окне (MPRIS); апплет звука Cinnamon показывает название и обложку. +7. Закрыл мини-окно — прогресс на сервере, медиатека в прежнем состоянии. Если фокус был у плеера (×, `Ctrl+W`, меню, конец фильма), активным становится окно медиатеки; если плеер шёл в фоне, фокус у другой программы не отнимается. + +### 3.3 Управление в плеере + +**Показываем управление:** курсор вошёл в окно, мышь сдвинулась, колесо, нажатие, любая клавиша, пауза/продолжение с медиаклавиши. + +**Скрываем:** курсор ушёл из окна — сразу (затухание 150 мс); курсор неподвижен 2 с в мини и 3 с в остальных режимах — скрываем и прячем курсор. На паузе те же правила: остаётся стоп-кадр. Тонкой полоски прогресса при скрытом управлении нет. + +**Не скрываем:** пока курсор над кнопками, таймлайном, панелью или подсказкой; пока тянут таймлайн, ползунок или окно; пока открыта панель или меню. Панель при уходе мыши из окна закрывается через 1,5 с (вернулся раньше — остаётся). + +Когда управление видно, субтитры поднимаются над OSD (`sub-pos`, §4.3). + +**Компоновки по ширине окна** (в точках, то есть с учётом «Масштаба интерфейса»: при 125 % переключаются раньше): + +- **≥ 900** — OSD как на ТВ: сверху тип, название, сезон/серия, часы, «закончится в», ×; снизу таймлайн с буфером, ⏮ ⟲ ⏯ ⟳ ⏭, чипы «Серии», Аудио, Субтитры, Качество, Direct/Транскод, громкость, весь экран. +- **480–900** — строка названия с × сверху и одна строка снизу: у серии ⏮ ⏯ ⏭, у фильма ⏯ ⟲ ⟳; время, таймлайн, громкость, ⋯, режим окна. +- **< 480** — ⏯ по центру с ⏮/⏭ (у фильма ⟲/⟳) по бокам, тонкий таймлайн снизу; ⋯ и режим — слева сверху, «К медиатеке» и × — справа. +- **Панели** (Аудио, Субтитры с превью, размером, цветом и фоном; Качество; Информация о потоке с «Дополнительно» только для чтения): в окне ≥ 900 и выше 420 — справа, как на ТВ, иначе листом на всё окно с прокруткой. Шторка «Серии / О фильме» в полной компоновке — снизу рядом серий с ‹ ›, в маленьком окне — листом со списком. + +**Мышь:** + +- щелчок по видео — пауза/продолжить (при открытой панели — закрыть её). В мини нажатие со сдвигом больше 4 px — уже перетаскивание окна; +- двойной щелчок — смена режима. Первый щелчок ставит паузу сразу, второй её снимает и меняет режим (короткая «запинка» звука) — вместо задержки ~0,4 с на каждый щелчок в ожидании двойного. Интервал — системный, не больше 0,5 с; +- колесо над видео — громкость ±5 %, над таймлайном — перемотка ±5 с; +- правая кнопка — меню: режим окна, Аудио, Субтитры, Качество, Информация, Серии / О фильме, К сериалу, «Отметить просмотренным», «На всех рабочих столах» (в мини), К медиатеке, Закрыть. В низком окне меню прокручивается, колесо за концом меню не крутит громкость; +- таймлайн: время под курсором, щелчок — переход, перетаскивание — перемотка. Превью кадров — когда на сервере будет trickplay. + +**Громкость:** ползунок до 100 %, колесом и ↑ — до 130 %. Громкость и «без звука» запоминаются, новый движок сразу стартует с ними. + +**Клавиатура** (по физическим клавишам, работает и в русской раскладке): + +| Клавиша | Действие | +|---|---| +| Пробел, K | пауза | +| ← / → | ±10 с (нажатия копятся: в движок уходит одна перемотка через 0,4 с) | +| Shift+← / → | ±60 с | +| 1–9 | переход на 10–90 % | +| ↑ / ↓ | громкость | +| M | без звука | +| S | пропустить заставку/титры или сразу следующая серия, если показана карточка | +| N / Shift+N | следующая / предыдущая серия | +| A / C | следующая аудиодорожка / субтитры (с подсказкой «Аудио: …») | +| F | весь экран | +| T | мини поверх всех ↔ обычное окно | +| Esc | меню → панель → выход из полного экрана; в обычном окне без панели ничего не делает | +| Ctrl+W | закрыть плеер | + +**Пропуск заставок, «Следующая через N с», автопереход** — логика ТВ (`SkipPlan`, `SegmentSet`) один в один, с настройкой «Кнопка / Сразу / Не пропускать». Когда управление скрыто, кнопка пропуска — плашка в правом нижнем углу на время сегмента; через 10 с прячется и возвращается вместе с OSD. При «Сразу» — без плашки. Карточка следующей серии в окне уже 480 — одной строкой. + +### 3.4 Медиатека на ПК + +- **Экраны те же, что на ТВ:** Вход, Главная, Медиатека, Фильм, Сериал, Поиск, Избранное, Человек, Коллекция/папка, Настройки. Фильм, сериал, человек и коллекция — во весь экран без меню, со стрелкой «‹» вверху слева. Не перенесены лента для оболочки ТВ (`LauncherFeed`), выбор движка, «Вывод видео», Dolby Vision и отладка. +- **Размеры:** dp ТВ-версии = px при масштабе 100 % (умножается на масштаб системы: Java на X11 берёт его из `GDK_SCALE` / Xft.dpi). Текст ~14–16 px. Поверх — «Масштаб интерфейса» 80–150 % (через `LocalDensity`, сразу в обоих окнах, включая OSD). +- **Под ширину окна** (от ~800 до 3440 px): сетки — столько колонок, сколько влезает; «Продолжить просмотр», кнопки, чипы и строка метаданных переносятся (`FlowRow`); страница фильма и сериала прокручивается целиком, если окно низкое. Боковое меню (`Shell`) на окне ≥ 1100 px открыто всегда, с подписями; уже — только иконки, раскрывается поверх содержимого при наведении. Библиотеки сервера — в меню, профиль — внизу. +- **Мышь:** наведение = фокус (`hoverFocus`): элемент под мышью получает тот же вид, что фокус на ТВ (кольцо, свечение); уход мыши фокус снимает; поле ввода, в котором печатают, фокус не отдаёт. Плитку библиотеки и сезон сериала выбирает щелчок, а не фокус — иначе мышь, проходя над плитками, переключала бы библиотеки. Правая кнопка на карточке, серии или сезоне — меню «просмотрено / не просмотрено» у курсора (закрывается щелчком мимо или Esc). +- **Ряды** (`ScrollRow`): колесо листает страницу, Shift+колесо и горизонтальный жест тачпада — ряд; при наведении по краям ряда — круглые ‹ ›. +- **Клавиатура:** стрелки двигают фокус, как пульт; первая стрелка, пока ничего не в фокусе, ставит его на запомненный (или первый) элемент экрана. При возврате «назад» фокус — на карточке, с которой ушли. `Ctrl+F` или `/` — поиск. «Назад» — Esc, `Alt+←`, Backspace (не в поле ввода), боковая кнопка мыши. +- **Поиск** — обычное поле ввода (фокус в нём при открытии), под ним недавние запросы и новинки или результаты по библиотекам и люди. +- **Вход:** сервер (найденные в LAN, сохранённые, «Ввести адрес»), профиль, имя и пароль с клавиатуры или Quick Connect с QR. «Сменить профиль» открывает экран входа на шаге профиля. +- **Настройки:** разделы ТВ плюс «Компьютер» (масштаб, «Открывать плеер», «Мини-окно на всех рабочих столах») и «Клавиши» (плеер, мышь в плеере, медиатека). «О приложении» — версия, устройство и список компонентов с лицензиями (§4.9). + +## 4. Архитектура + +### 4.1 Модули + +``` +core:common KMP (android + desktop): Log, monotonicMs(), каталоги платформы и DataStore +core:jellyfin KMP: сессия Jellyfin, ServerRouting +core:data KMP: репозитории +playback:api KMP: интерфейсы движка +playback:core KMP: PlaybackSession, EngineSelector, отчёты, сторожа +ui:shared KMP + Compose Multiplatform: ViewModel'и, Screen, CardMapper, подписи, тема, шрифты, иконки, компоненты +app ТВ (Android): экраны с фокусом под пульт, LauncherFeed +playback:engine-exo только Android +playback:engine-vlc только Android +playback:engine-mpv только desktop (JVM): libmpv через JNA, свой контекст GL, кадр для Compose +desktop приложение ПК: окна, экраны, плеер, MPRIS, хранитель экрана, настройки, пакет .deb +``` + +Архитектура движков общая: `EngineProvider` → `EngineSelector` → `PlaybackSession`. На ПК в наборе один провайдер — `mpv` (`mpvEngineModule`); отчёты о прогрессе, сторож зависаний, запоминание дорожек, ограничение качества и транскод работают из общего кода без изменений. + +Граф Koin ПК: `jellyfinModule`, `dataModule`, `playbackModule`, `mpvEngineModule`, `uiModule`, `desktopModule()`. + +### 4.2 Что пришлось развести в общем коде + +- `android.os.SystemClock` → `monotonicMs()` (expect/actual): на Android `elapsedRealtime()` (идёт и во сне ТВ — на нём держатся сроки кэша), на ПК `System.nanoTime()`. +- `Context` + `preferencesDataStore` → `AppDirs` + `PreferenceStores` (по DataStore на файл). Android — прежние `files/datastore` и `files` (данные на ТВ сохранились), ПК — XDG-каталоги. +- `Build.MANUFACTURER/MODEL` → `JellyfinPlatform`: на Android — `AndroidJellyfinPlatform`, на ПК — `DesktopJellyfinPlatform` с именем «SleepyOwl · » и id устройства — хэшем `/etc/machine-id`. +- `PlaybackEngine.attachVideo(FrameLayout)` убран из общего интерфейса: Exo и VLC реализуют `AndroidVideoOutput`, сессия отдаёт каждый новый движок хозяину вывода `PlaybackSession.attachVideo(VideoHost)` до `load` (и при смене движка на лету). На ТВ хозяин — `FrameLayoutVideoHost`, на ПК — поверхность кадров mpv. Дополнительные возможности движка плеер берёт через `engine as? …` (`AudioDelayCapable`, `SubtitleDelayCapable`, `SubtitleStyleable`). +- `DeviceCapabilitiesProbe` стал интерфейсом: Android-пробник (MediaCodecList, Display, AudioTrack) — в `androidMain`, `Codecs.passthroughEncoding` / `encodingName` — расширения в `Codecs.android.kt`; на ПК `PcCapabilitiesProbe` — имя ПК и процессора, декодеры — дело mpv. +- Timber → свой `Log` с тем же видом вызовов (`Log.w(e, "… %s", x)`): на Android пишет в Timber с тегом вызывающего класса, на ПК — в stderr и файл. +- `org.json` (часть Android, на ПК его нет) → `kotlinx.serialization` (`JsonObject`), формат хранения прежний: аккаунты, выбранные дорожки, история поиска, ответ discovery. +- Hilt → Koin: ViewModel'и с параметром — `koinViewModel { parametersOf(id) }`, набор движков — `getAll()` (провайдер объявляется с `bind EngineProvider::class`). +- `androidx.tv.material3` → свои Text/Button/Surface на `compose.foundation`; `tv-material` и `material-icons-core` ушли из ТВ-сборки. +- **Картинки, запечённые через `android.graphics`** — один интерфейс, две реализации. На Android код прежний (вылизан под Mali), на ПК — простой: свечение фокуса — размытие Skia одним проходом; картинка с сервера — `clip` и градиент поверх, BlurHash растягивается из 32×32 (разбор BlurHash общий). QR-код (пиксели в `argbImageBitmap`) и пунктирная рамка (Canvas Compose) стали общими. +- `Context` в `CardMapper` и `SettingsViewModel` → `UiPlatform` (плотность экрана для размера картинок с сервера, строки версии и устройства, доступность отладки). +- Ресурсы: логотип и шрифты Onest / JetBrains Mono лежат в `ui:shared` (`res/drawable-xhdpi`, `res/font`); `src/androidMain/res` подключён и к ресурсам ПК, файлы не дублируются. +- `android.view.KeyEvent` и фокус под пульт остались в ТВ-экранах. +- Мелкие правки для ПК: `secondaryClick` в `clickableWithMenu` (expect/actual: на Android пустой, на ПК — правая кнопка), открытый `SearchViewModel.setQuery`, `LibraryViewModel.selectLibrary(id)`, `PlaybackSession.awaitStopReports()` (§4.8). + +**Влияние на ТВ.** Переход на KMP, Koin и Compose Multiplatform скорость ТВ не изменил (release с baseline profile, сборки чередовались): + +| | До перехода (Hilt) | KMP + Koin | + `ui:shared` (CMP) | +|---|---|---|---| +| Холодный запуск, `am start -W`, среднее | 257 мс | 258 мс | 263 мс | +| `ui_bench right` на главной, кадр p50 / p90 | 12–19 / 19–32 мс | 12–19 / 19–32 мс | 15–19 / 31–32 мс | +| release APK | 52,85 МБ | 52,87 МБ | 52,84 МБ | + +Столбцы — из разных прогонов: при прямом сравнении последних двух сборок вперемешку холодный запуск был 274 и 263 мс — разброс между прогонами больше разницы между сборками. + +Compose Multiplatform 1.12.2 на Android тянет те же androidx Compose 1.12.x, что и BOM приложения; плагин Gradle CMP для этого не нужен — хватает плагина компилятора Compose и координат `org.jetbrains.compose.*` в `commonMain`. После перехода baseline profile переснят (27 740 правил: Koin вместо Hilt). + +### 4.3 Движок mpv (`playback:engine-mpv`) + +**Привязка.** `LibMpv.kt` — client API (`mpv_create`, `mpv_initialize`, `mpv_set_option_string`, `mpv_command_async`, `mpv_observe_property`, `mpv_wait_event`) и render API (`mpv_render_context_create`, `…_render`, `…_set_update_callback`, `…_report_swap`); `Gl.kt` — EGL и OpenGL через glvnd. До `mpv_create` — `setlocale(LC_NUMERIC, "C")`: в русской локали mpv путает дроби. + +**`MpvEngine`** — `PlaybackEngine` + `AudioDelayCapable`, `SubtitleDelayCapable`, `SubtitleStyleable`. Одно ядро mpv на воспроизведение; из интерфейса — только асинхронные команды; события и свойства — в своём потоке (`mpv-events`). Пока контекст отрисовки не готов, `loadfile` с предшествующими командами ждёт. + +**Опции:** + +- `vo=libmpv`, `hwdec=auto-safe` (NVDEC на NVIDIA, VAAPI на Intel/AMD); +- `config=no`, `terminal=no`, `input-default-bindings=no`, `input-vo-keyboard=no`, `osc=no`, `ytdl=no`, `idle=yes`; +- `keep-open=yes`, `hr-seek=yes`, `cache=yes`, `demuxer-max-bytes=150MiB`, `demuxer-max-back-bytes=50MiB`; +- `audio-channels=auto-safe` (сведение в стерео для колонок и наушников), `ao=pipewire,pulse,alsa`, `audio-client-name=SleepyOwl`; +- `tone-mapping=auto` (HDR → SDR); +- `sub-auto=no`, `sub-ass-override=scale` (ASS сохраняют свои стили); +- `user-agent=SleepyOwl`; +- на каждый файл: `deinterlace=yes`, если Jellyfin говорит `IsInterlaced` (в 0.37 нет `auto`), иначе `no`; `start` — позиция продолжения. +- Для проверок: `SLEEPYOWL_MPV_OPTIONS="mute=yes,…"` (дополнительные опции), `SLEEPYOWL_MPV_LOG=v` (уровень журнала mpv; по умолчанию `warn`). + +**Адрес потока.** Ссылки на поток и внешние субтитры строит общий `JellyfinSession` — с токеном в параметре `ApiKey` (общий путь всех движков). Если токен передавать заголовком, то только через `http-header-fields-append` или коротким `Authorization: MediaBrowser Token="…"`: обычный `http-header-fields` режет значение по запятым (§5.1). + +**Дорожки.** Индекс Jellyfin → `aid`/`sid` mpv. До открытия файла `aid`/`sid` ставятся по месту среди дорожек того же типа (`embeddedOrdinal()`, нумерация с 1), чтобы не было переключения на старте; после `file-loaded` движок читает `track-list` (`id`, `type`, `ff-index`, `external-filename`) и уточняет: встроенные — по `ff-index` (это индекс Jellyfin минус число внешних файлов: Jellyfin нумерует внешние первыми), внешние субтитры — по адресу; запасной путь — место среди дорожек типа. Внешние субтитры добавляются по одному `change-list sub-files append `: список `sub-files` делится по `:`, а в адресах их полно. В транскоде аудио не трогаем — его выбрал сервер. + +**Стиль субтитров** (`SubtitleStyle`) — на лету, без перезапуска: `sub-scale`, `sub-color`, обводка с тенью или `opaque-box` (подложка — цвет обводки), `sub-back-color`. Подъём над OSD — `sub-pos` (100 = низ, % высоты картинки), а не `sub-margin-y`: тот не двигает ASS. + +**События → состояние:** + +- `pause` → `playWhenReady`; пока файл не открыт, `paused-for-cache` или `seeking` — BUFFERING; `eof-reached` — ENDED; +- первый кадр (`FirstFrameRendered`) — из потока отрисовки, когда известен размер видео; +- `end-file` с ошибкой → вид `EngineError` по коду mpv: загрузка → NETWORK, формат → FORMAT, звук, видео; +- `seekable` → `isSeekable`; `demuxer-cache-time` → полоса буфера; +- `frame-drop-count`, `hwdec-current`, `video-codec`, `audio-codec-name`, `current-ao`, размер кадра в памяти, сведения о GL → «Информация о потоке». + +**`MpvEngineProvider`:** `evaluate()` → Direct почти для всего; `deviceProfile()` — «всё напрямую»: все контейнеры и кодеки, субтитры встроенные и внешние. «Качество» ниже оригинала — транскод сервера в HLS, mpv его играет. Сквозного вывода звука на ресивер нет (ПК — колонки и наушники). + +**Освобождение** — в своём потоке: поток событий, поток отрисовки (контекст отрисовки mpv освобождается до `mpv_terminate_destroy`), ядро; ~0,3 с. + +### 4.4 Как видео mpv попадает в Compose + +Рассматривались три варианта, A и B были сделаны и измерены в отдельном прототипе (модуль `:desktop-proto`, позже удалён из репозитория; движок вырос из его варианта B): + +- **A. Общий контекст, без копирования.** mpv рисует в контексте GLX самого Skiko, в фазе рисования Compose, в свою текстуру, которую Skia «усыновляет» (`Image.adoptTextureFrom`). `DirectContext` достаётся рефлексией (`SkiaLayer` → `getRedrawer` → `contextHandler.context`); перед отрисовкой mpv состояние GL, оставленное Skia, сбрасывается, после — `resetGLAll()`; `video-timing-offset=0`. +- **B. Свой контекст у mpv и копия кадра.** Свой поток, невидимый контекст EGL (pbuffer), FBO, `glReadPixels` в PBO → память кадра, три буфера (показываемый, последний готовый, записываемый; кадр, заменённый до показа, считается потерянным). Память кадра — `Data` Skia, `Image` поверх неё без копирования; без `FLIP_Y` кадр идёт сверху вниз, как ждёт Skia. +- **C. Программная отрисовка mpv (SW render API)** — только на крайний случай: медленно, хуже масштабирование. Не понадобился. + +**Замеры прототипа** (30 с после 4 с прогрева, звук выключен; CPU — % одного ядра для всего процесса, GPU — SM процесса по `nvidia-smi pmon`; A — со сбросом GL). + +Весь экран 3440×1440, 175 Гц: + +| Видео | A: к/с, потери | A: CPU | A: GPU | B: к/с, потери | B: копия | B: CPU | B: GPU | +|---|---|---|---|---|---|---|---| +| H.264 1080p24 | 24, 0 | 8,3 % | 1 % | 24, 0 | 2,4 мс | 23,4 % | 6 % | +| HEVC 10 бит 4K HDR10 | 24, 0 | 11,2 % | 2 % | 24, 0 | 2,8 мс | 27,3 % | 5 % | +| DV P8.1 4K | 24, 0 | 9,5 % | 1 % | 24, 0 | 2,8 мс | 25,3 % | 7 % | +| H.264 720p60 | 60, 0 | 14,3 % | 5 % | 60, 0 | 2,3 мс | 48,2 % | 20 % | + +Мини-окно 640×360 (640×266 у 2,39:1), портретный монитор 144 Гц, окно без фокуса: + +| Видео | A: к/с, потери | A: CPU | B: к/с, потери | B: копия | B: CPU | +|---|---|---|---|---|---| +| H.264 1080p24 | 24, 0 | 9,6 % | 24, 0 | 0,3 мс | 9,6 % | +| HEVC 10 бит 4K HDR10 | 24, 0 | 11,6 % | 24, 0 | 0,5 мс | 15,1 % | +| DV P8.1 4K | 24, 0 | 11,2 % | 24, 0 | 0,6 мс | 15,1 % | +| H.264 720p60 | 60, 0 | 14,8 % | 60, 1 | 0,5 мс | 28,6 % | + +- Пропусков mpv нет нигде; на 60p у обоих по одному долгому интервалу (32 мс) в начале прогона. Разброс интервалов между новыми кадрами в Compose — 0,5–0,6 мс (1,1 мс у B на 60p); это время отрисовки, а не показа — рывки 24p так не измерить. +- **Куда уходит лишнее у B на весь экран** (разбивка по потокам из `/proc//task`): поток AWT 10,7 % на 24p и 23,8 % на 60p — Skia каждый кадр загружает в текстуру картинку 3440×1440 (20 МБ) из памяти; поток отрисовки B 7–8 % и 15,7 % — `glReadPixels` и копия. У A поток AWT 2,5–5 %. +- **Откуда 10–15 % в мини-окне:** в 30-секундном прогоне 2–5 % — JIT (прогрев); остальное поровну между mpv (потоки `core`, `demux`, `vo`, `cuda`, звук — 2–4 %) и Compose (поток AWT 3 %, Skiko ~1 %, события и корутины ~1 %), у B ещё поток отрисовки 2–3 %. **Установившийся режим** (минута прогрева, минута замера, H.264 1080p24): **A 5,8 %, B 6,6 %** одного ядра; из них mpv ~1,5 %, поток AWT 1,2–1,5 %, Skiko 0,7 %, JIT 1 %, поток отрисовки B 1,2 %. + +**Выбран B.** Почему: + +- в мини-окне — главном сценарии — A не даёт выигрыша (5,8 % против 6,6 % одного ядра); +- на весь экран разница 15–34 % одного ядра — на 24-поточном процессоре это 0,6–1,4 % всего CPU: заметно в цифрах, не в работе; потерь кадров нет у обоих; +- у A три настоящих минуса: рефлексия во внутренности Skiko (непубличный API, может сломаться с обновлением Compose); общее состояние GL — без сброса HDR и DV были чёрными (§5.2), и любое обновление Skia может оставить что-то ещё; контекст отрисовки mpv создаётся в потоке интерфейса, в первом кадре Compose — вместе с запуском CUDA окно замирает на ~2 с и первый кадр видео приходит на ~2 с позже, чем в B (там ~0,3 с, параллельно с открытием окна); +- у B mpv живёт в своём потоке и контексте и ничего не знает об окне: окно можно пересоздать или поменять ему рамку — видео не заметит. + +**B удешевлён в движке** (`MpvVideo.kt`): + +- **FBO — размер области видео, но не больше самого видео** (`renderSize`): если видео пришлось бы увеличивать, FBO уменьшается с сохранением пропорций области (mpv по-прежнему рисует чёрные поля), дальше растягивает Skia (Catmull-Rom). Для фильма 1080p на весь 3440×1440 кадр в памяти получается в несколько раз меньше окна (таблица ниже). На 4K ничего не меняется — кадр и так размером с окно. Цена — растягивает Skia, а не масштабатор mpv. +- **PBO с постоянным отображением** (`glBufferStorage` + `MAP_PERSISTENT | MAP_COHERENT`), без map/unmap на кадр. +- **Вторая копия (PBO → память Skia) оставлена сознательно:** картинка Skia поверх памяти PBO может пережить буфер — Compose повторяет записанную картинку и после остановки движка, а у `Data` Skia есть счётчик ссылок. + +**Итоговые замеры B** (окна без фокуса, звук выключен, 20 с после 12 с прогрева, CPU — % одного ядра, JIT ещё ~2–3 %): + +| Окно | Видео | Кадр в памяти | CPU | поток AWT | поток отрисовки | B в прототипе | +|---|---|---|---|---|---|---| +| мини 640×360 | H.264 1080p24 | 640×323 | 9,7 % | 2,2 % | 2,0 % | — | +| весь портретный 1080×1920 | H.264 1080p24 | 1056×1841 | 19,1 % | 6,4 % | 4,4 % | — | +| весь основной (3440×1367) | H.264 1080p24 | 1107×440 | **10,0 %** | 2,5 % | 2,1 % | 23,4 % | +| весь основной (3440×1367) | HEVC 4K HDR10 | 3440×1367 | 25,5 % | 10,0 % | 7,0 % | 27,3 % | + +Цель (≤ ~15 % одного ядра на 24p на весь экран) выполнена: фильм меньше окна стоит как мини-окно; к варианту A возвращаться незачем. Поправка по ходу: первый кадр иногда рисовался в размер окна, когда mpv уже сообщил ширину видео, а высоту ещё нет. + +Код варианта A в репозитории не сохранился; суть — в описании выше и в §5.2. + +### 4.5 Окно плеера: техника X11 + +Окно плеера создаётся само (`Window(create = …)`, без `WindowState` Compose): место и размер — дело WM и `PlayerPlacement`. Работа с X11 — `X11.kt` (Xlib через JNA): рамка, полный экран, состояние WM, `startMoveResize`, `geometry`, `moveResize`, `setSizeHints` (PAspect + PMinSize), `setSticky`. + +**Рамка — от WM, через `_MOTIF_WM_HINTS`.** Окно AWT создаётся без рамки (`undecorated`), рамку включает и выключает WM по подсказке `_MOTIF_WM_HINTS` (так делает mpv; Muffin добавляет или убирает заголовок 32 px у живого окна). AWT этого не замечает: его insets остаются нулевыми, Compose занимает всю клиентскую область. Окно не пересоздаётся. Рассматривались и отвергнуты: + +- пересоздание окна при смене режима — работало после исправлений, но с дефектами (§5.3); +- всегда без рамки и своя строка заголовка — не понадобилось; +- рамка AWT — с ней Muffin не выводит окно из полного экрана (§5.3). + +**Полный экран** — `_NET_WM_STATE_FULLSCREEN` напрямую, на мониторе с окном. Запрос окну, которое WM ещё не взял под управление (нет `WM_STATE`), пропадает — при старте в полном экране ждём `WM_STATE`. + +**«Поверх всех»** — `_NET_WM_STATE_ABOVE` (только в мини), «на всех рабочих столах» — `_NET_WM_STATE_STICKY`. + +**Порядок смены режима:** запомнить место старого режима → выйти из полного экрана и дождаться, пока WM снимет `FULLSCREEN` (~35 мс; Muffin сам возвращает размер «до полного экрана», причём позже нас) → рамка, «поверх всех», `STICKY`, подсказки размера → место нового режима через `XMoveResizeWindow` с повторами и поправкой на рамку. Подробности — §5.3. + +**Перетаскивание и размер** — `_NET_WM_MOVERESIZE`: окно двигает сам WM — плавно, между мониторами. Перед запросом отпускается захват мыши AWT (`XUngrabPointer` на соединении AWT под `awtLock`), а Compose получает синтетическое отпускание кнопки — иначе он остаётся в «нажато». В запрос передаётся **точка нажатия**, а не текущая: иначе окно отстаёт от курсора на путь до порога перетаскивания. Запасной путь — двигать окно самим через `setLocation` (проверен в прототипе, в приложении не нужен). + +**Пропорции** мини-окна держит сам Muffin по `WM_NORMAL_HINTS` (PAspect, PMinSize 320×180) — при условии, что AWT не сотрёт подсказки (§5.3). Измерено: 840×472 и 569×320 у 16:9, 760×316 у 2,4:1. + +**Фокус** — один раз при открытии плеера по действию пользователя. При автопереходе к следующей серии и прочих событиях окно не зовёт `toFront()` / `requestFocus()` — в Muffin они забирают фокус по-настоящему. У окна без рамки AWT фокус надо отдать холсту Skiko (`requestFocusInWindow` самому вложенному компоненту), иначе клавиши теряются до первого щелчка. + +**Наведение и уход мыши** — события Enter/Exit/Move на корне окна; X11 присылает их (и колесо) окну без фокуса. + +### 4.6 Интерфейс плеера (`desktop/…/Player*.kt`) + +- `PlayerWindow.kt` — `DesktopPlayer` (что играет, режим и `windowedMode`, `switchTo`, `toggleOnTop`, `toggleFullscreen`, `cycleMode`, `showLibrary`, `openPage`), `PlayerAudio` (громкость и «без звука»), `PlayerPlacement`, `PlayerMode` (Mini, Window, Fullscreen). +- `PlayerFrame.kt` — окно и его режимы, перетаскивание, восстановление фокуса при закрытии. +- `PlayerUi.kt` — состояние (`PlayerUiState`: показ OSD, кто держит управление, панель, меню, перемотка, пропуски), три компоновки, мышь и клавиши, `NowPlaying` и `PlayerCommands` (команды извне — медиаклавиши показывают управление, как клавиши). +- `PlayerControls.kt` — кнопки и чипы ТВ с подсветкой от наведения, значки ПК, таймлайн, громкость, подсказка-тост. +- `PlayerPanels.kt` — панели и шторка «Серии / О фильме»; `PlayerPrompts.kt` — кнопка пропуска, «Заставка пропущена», карточка следующей серии, загрузка, ошибка, меню правой кнопки. +- Общий `PlayerViewModel` и `PlaybackSession` — без изменений. + +### 4.7 Каркас приложения и медиатека + +- **`Main.kt`:** один экземпляр → `MpvVideo.prepareProcess()` (до любого окна, §5.2) → лог в файл → WM_CLASS → Koin → Coil → главное окно; выход — через `_exit` (§4.8). +- **Навигация — свой стек** (`NavStack`), без navigation3: у каждого экрана свои ViewModel'и (очищаются, когда экран уходит из стека) и сохранённое состояние; рисуется только верхний. +- **«Назад»** — `BackDispatcher`: сначала обработчик экрана (шаги входа), потом стек. Клавиши — в `onKeyEvent` окна (§5.4), боковая кнопка мыши — через `AWTEventListener` (§5.4). +- **Coil** — клиент API с `ServerRouting` (LAN-адрес сервера, если доступен, иначе публичный — работает на ПК без изменений), диск `~/.cache/sleepyowl/images` 500 МБ, память — четверть кучи. +- **Экраны** — по файлу на экран в `desktop/`, той же раскладкой и видом, что на ТВ, на общих ViewModel'ях. +- `--play [--start <с>]` — открыть плеер сразу после восстановления сессии (для проверок). + +### 4.8 Приложение и система + +**Файлы** (с учётом `XDG_*`): + +| Где | Что | +|---|---| +| `~/.config/sleepyowl` | настройки и аккаунты: `jellyfin_session.preferences_pb`, `desktop.preferences_pb` (настройки ПК), `desktop_player.preferences_pb` (место окон, режим, громкость) и DataStore общего кода | +| `~/.cache/sleepyowl` | картинки Coil (`images/`), снимок главной, обложки для MPRIS (`mpris/`) | +| `~/.local/state/sleepyowl/log` | `sleepyowl.log`, прошлый запуск — `sleepyowl.1.log`; также stderr | +| `$XDG_RUNTIME_DIR/sleepyowl` | `sleepyowl.lock`, `sleepyowl.sock` | + +Токены — в файле DataStore, как на ТВ; libsecret — потом, если понадобится. + +**Один экземпляр** (`SingleInstance`): блокировка `sleepyowl.lock` + локальный сокет `sleepyowl.sock`. Второй запуск просит первый поднять окно и выходит. + +**WM_CLASS.** Java по умолчанию ставит WM_CLASS по имени главного класса, и панель задач группирует окна неправильно («java»). До первого окна WM_CLASS задаётся `sleepyowl` рефлексией на `sun.awt.X11.XToolkit.awtAppClassName` (нужны `--add-opens java.desktop/sun.awt.X11` и `java.desktop/sun.awt`); в .desktop-файле — `StartupWMClass=sleepyowl`. Окна медиатеки и плеера — под одним значком. Значки 16…512 px (сова на синем скруглённом квадрате) генерирует `tools/logo/make_assets.py` в `desktop/src/main/resources/icons/`. + +**Выход.** Сохранить всё → дождаться отчётов об остановке на сервер (`PlaybackSession.awaitStopReports()`) и записи места окна (до 3 с) → закрыть D-Bus, освободить блокировку, остановить Koin → `_exit(0)` из libc, без деструкторов библиотек. `exitProcess` не подходит: примерно каждый третий выход зависал (§5.2). Закрытие окна — выход за ~0,2 с. + +**MPRIS** (`SystemMedia.kt`, dbus-java 5.2.2 — чистая Java на unix-сокетах JDK, без нативных библиотек): + +- имя `org.mpris.MediaPlayer2.sleepyowl` занимается, пока открыто окно плеера, и отпускается при закрытии (плеер пропадает из апплета); при смене серии не отпускается. Второй экземпляр на той же шине берёт `org.mpris.MediaPlayer2.sleepyowl.instance`; +- метаданные: у серии название «Сезон 1 · Серия 2 · …» и исполнитель — сериал, у фильма — название и «год · жанры»; длительность; обложка — постер сериала или фильма, скачивается своим клиентом (с маршрутом по LAN) в `~/.cache/sleepyowl/mpris` и отдаётся как `file:///…`; статус, позиция, громкость; +- команды: Play, Pause, PlayPause, Stop (= пауза: «остановить» в окне плеера — это пауза, закрыть — ×), Next/Previous (серии), Seek, SetPosition, Volume (запись), Raise; +- изменения — `PropertiesChanged` только с изменившимися свойствами; скачок позиции больше 2 с (перемотка откуда угодно) — сигнал `Seeked` (проверка раз в секунду). Пока грузится следующая серия, отдаются прежние данные — плеер не мигает в апплете. + +**Хранитель экрана** — `org.freedesktop.ScreenSaver.Inhibit`, пока видео идёт (не на паузе, не в ошибке, не в конце), `UnInhibit` на паузе и при закрытии. mpv в режиме render API сам этого не делает. В Cinnamon сервис даёт `csd-screensaver-proxy`; запрет привязан к соединению D-Bus — если приложение упадёт, система снимет его сама. + +Вызовы D-Bus — в своём потоке; нет шины или ошибка — плеер работает без них (запись в лог). + +**Настройки ПК** (`DesktopSettings.kt`, читаются до первого окна): «Масштаб интерфейса» 80–150 % (переменная `SLEEPYOWL_SCALE` важнее — в настройках об этом надпись), «Открывать плеер», «Мини-окно на всех рабочих столах». + +### 4.9 Сборка и установка + +``` +./gradlew :desktop:run # запуск из Gradle +tools/linux/desktop.sh # запуск без Gradle вокруг процесса (DESKTOP_NOBUILD=1 — без сборки) +./gradlew :desktop:packageDeb # → desktop/build/deb/sleepyowl_0.1.0-<ГГГГММДДЧЧММ>_amd64.deb, ~65 МБ +sudo apt install ./desktop/build/deb/sleepyowl_*.deb +sudo apt remove sleepyowl # ~/.config/sleepyowl остаётся +``` + +Нужен JDK 21 (`JAVA_HOME` или `local/dev.env`, §7.5). + +**Что в пакете.** Образ jpackage из JDK 21 (`createDistributable` плагина Compose) со своей урезанной JRE (jlink) — в `/opt/sleepyowl`; `/usr/bin/sleepyowl` — ссылка на запускалку; `/usr/share/applications/sleepyowl.desktop` (`StartupWMClass=sleepyowl`, раздел «Аудио и видео»); значки 16…512 — в `hicolor`. Меню и кэш значков обновляют триггеры dpkg, своих скриптов в пакете нет. + +**Решения:** + +- **.deb собирается своей задачей** (`PackageDeb` в `desktop/build.gradle.kts`, через `dpkg-deb`), а не `packageDeb` jpackage: его пакет не умеет зависеть от `libmpv2` — libmpv загружается через JNA, и зависимость сама не находится. Свой пакет даёт ещё короткое имя в `/usr/bin` и меню без лишних скриптов. +- **libmpv — системная** `libmpv2 (>= 0.37)`: на проверенных файлах 0.37 не хуже 0.41 (§6), своя libmpv не нужна. Остальные зависимости — то, что `dpkg-shlibdeps` нашёл у библиотек JRE, Skiko и JNA (имена Mint 22 / Ubuntu 24.04: `libc6 (>= 2.34)`, `libstdc++6`, `libfontconfig1`, `libx11-6`, `libxext6`, `libxi6`, `libxrender1`, `libxtst6`, `libasound2t64 | libasound2`), плюс то, что грузится через JNA: `libegl1`, `libopengl0`, `libgl1`. +- **Версия пакета** — `0.1.0-ГГГГММДДЧЧММ` (время сборки): `apt install` любой новой сборки идёт как обновление. Номер версии приложения — одно место, `appVersion` в `desktop/build.gradle.kts`; `BuildInfo.VERSION` генерируется из него. +- **Модули JRE:** `suggestRuntimeModules` дал `java.instrument`, `jdk.security.auth` (dbus-java), `jdk.unsupported`. Добавлены два, которых jdeps не видит: `jdk.crypto.ec` (TLS к публичному адресу сервера по https) и `jdk.localedata` — без него `Locale.getDisplayLanguage(ru)` даёт названия языков дорожек по-английски. +- **Права файлов в пакете** выставляются явно (755/644): при `umask 002` иначе получается групповая запись. + +**Лицензии** («О приложении», `LicenseScreen.kt`) — свой список ПК: mpv и FFmpeg (системная libmpv, GPL-2.0+), OpenJDK в пакете (GPL-2.0 + Classpath Exception), Compose Multiplatform и Skiko, Skia (BSD-3), JNA, dbus-java. Тексты — общие с ТВ из `app/src/main/assets/licenses` и свои три в `desktop/src/main/resources/licenses` (GPL-2 из `/usr/share/common-licenses`, лицензия OpenJDK из `legal/java.base` образа, LICENSE Skia). `LicensesTest` проверяет, что у каждой строки есть текст. Экран текста листается колесом, ↑/↓, PgUp/PgDn, пробелом; Esc — назад. + +## 5. Находки и ловушки + +### 5.1 mpv и libmpv + +- **Render API рисует только старым рендерером `gpu`, не `gpu-next`** — и в 0.37, и в 0.41: в `vo_libmpv.c` есть лишь бэкенды `gpu` и `sw`. + - HDR → SDR у `gpu` нормальный, чуть мягче по контрасту, чем у `gpu-next`. + - DV показывается по базовому слою (в 0.41 это явно делает `mp_image_params_restore_dovi_mapping`) — для P8.x это правильно. + - У DV профиля 5 базового слоя нет → по коду ждём неправильных (зелёно-фиолетовых) цветов. Не проверено: в тестовой медиатеке P5 нет, а образец из набора FFmpeg (`fate-suite/mov/dovi-p5.mp4`) — чёрный кадр. Выход, если подтвердится: `evaluate` → Degraded (транскод сервера с тонмаппингом) или `vf=libplacebo`. + - Зато дешевле: в окне 640×360 GPU 3 % против 13–19 % у `gpu-next`. +- **`http-header-fields` режет значение по запятым:** заголовок `MediaBrowser Client="…", Device="…", Token="…"` доходит до сервера тремя обрывками (проверено на локальном HTTP-сервере). Нужно `http-header-fields-append` (одно значение без разбора) или только `Authorization: MediaBrowser Token="…"` — в нём запятых нет. +- **Список `sub-files` делится по `:`** — адреса внешних субтитров добавлять по одному через `change-list sub-files append`. +- **`deinterlace` в 0.37 — только `yes`/`no`**, `auto` появился позже: включать по `IsInterlaced` из Jellyfin. С nvdec 0.37 деинтерлейсит на GPU (`yadif_cuda`, CPU 3 % на 1080i50). В Flatpak-сборке 0.41 нет CUDA-фильтров ffmpeg → `bwdif` на CPU, 20 %. DVD 576i: без деинтерлейса — 25 к/с с гребёнкой, с `deinterlace=yes` — `bwdif` на CPU, 50 к/с, CPU 10 %. +- **`hwdec=auto-safe` на RTX 4090** выбирает nvdec для H.264, HEVC и AV1. MPEG-2, MPEG-4 ASP (нет в `hwdec-codecs` по умолчанию) и H.264 Hi10P (NVDEC не умеет H.264 10 бит) идут программно, CPU ≤ 19 % одного ядра. 0.41 сначала пробует cuda/vulkan/vaapi — ошибки в логе безвредны. VAAPI на ПК без встроенной графики нет: драйвер VAAPI для NVIDIA не загружается. +- **`sub-margin-y` не двигает субтитры ASS** — `sub-pos` двигает; надписи с `\pos` libass оставляет на месте. +- **Локаль:** до `mpv_create` — `setlocale(LC_NUMERIC, "C")`, иначе в русской локали дроби разбираются неверно. +- Безвредный шум в логе: «Multiple Dolby Vision RPUs» (DV P8.1), «mmco: unref short failure» (H.264 1080i50 после перемотки — от самого файла, картинка восстанавливается). +- Номера экранов mpv (`--screen`) не совпадают с `xrandr`; приложение берёт мониторы из AWT, его это не касается. +- **Синхронизация 24p с экраном** (`display-resample`) в render API работает только при точной обратной связи о кадрах; частота кадров Compose привязана к экрану (60/144/175 Гц) — на весь экран возможны лёгкие рывки 24p. Автоматически не измерить (§8). + +### 5.2 OpenGL, glvnd, Skiko + +- **glvnd и чужой контекст EGL.** На NVIDIA контекст EGL mpv не становился текущим, если до него уже рисовало окно Compose: `eglMakeCurrent` возвращал ошибку с кодом `EGL_SUCCESS` — и с pbuffer, и без поверхности. На Mesa всё работало, а прототип создавал контекст до окна — поэтому всплыло поздно. Причина — libglvnd: он переписывает точки входа GL под первого поставщика (GLX Skiko) и потом отказывает контексту другого API. Лечится `__GLVND_DISALLOW_PATCHING=1`. libGLdispatch читает переменную при загрузке, а грузится он при первой отрисовке Skiko — поэтому `MpvVideo.prepareProcess()` первым делом в `main` ставит её через `setenv`. Ручная проверка — `playback/engine-mpv/src/test/.../EglProbe.kt`. +- **Зависание при выходе** (примерно каждый третий запуск, в обоих вариантах вывода видео). По gdb: JVM в `exit()` вызывает деструктор libGLX (glvnd, через неё рисует Skiko), он зовёт `XESetCloseDisplay` и ждёт блокировку X-дисплея AWT, а её держит поток AWT, который JVM уже заморозила для выхода. Обход — после своей уборки `_exit(0)`: 10 запусков из 10 выходят чисто. SIGTERM идёт обычным путём и может зависнуть. +- **Общий контекст со Skia (вариант A):** Skia оставляет своё состояние GL — включённое смешивание (`GL_BLEND`), свой объект сэмплера на текстурном блоке 0, свою программу; mpv считает состояние исходным. Итог — HDR10 и DV рисовались чёрным (несколько проходов через промежуточные буферы `rgba16f`), а H.264 SDR выглядел нормально — короткий прогон на SDR этого не замечает. Сброс состояния перед `mpv_render_context_render` чинит всё. +- **На Xvfb у Skiko нет GL** («Cannot create Linux GL context») — рисует программно, снимки интерфейса верные. Видео на стенде рисует программный GL llvmpipe (§7.2). + +### 5.3 AWT, X11 и Muffin + +- **Muffin не выводит из полного экрана окно AWT с рамкой AWT** — ни через Compose `WindowPlacement.Fullscreen`, ни через `_NET_WM_STATE` (даже `wmctrl -b remove,fullscreen`, даже у простого `java.awt.Frame`); у xclock и у окна AWT без рамки работает. Поэтому окно плеера — без рамки AWT, рамку ставит WM по `_MOTIF_WM_HINTS`. Главному окну полный экран не нужен — оно с рамкой AWT. +- **AWT при каждом своём `reshape` переписывает `WM_NORMAL_HINTS` целиком** (`XBaseWindow.setSizeHints`: свои флаги, `NorthWestGravity`) — PAspect, PMinSize и `StaticGravity` пропадают. При изменении размера мышью это происходит на каждом шаге (~30–40 перезаписей за движение), и не из-за `WindowState` Compose: окно без `WindowState` теряет подсказки так же. Без защиты размер мышью шёл без пропорций (680×260 при видео 2,4:1). Обход: `ComponentListener` на перемещение и размер → `Toolkit.sync()` (запись AWT может ещё лежать в буфере его соединения) → если подсказки не наши, записать заново. Пока мини-окно не подогнано под видео — вместе с подгонкой, иначе Muffin подгоняет размер под пропорции по-своему (504×210 вместо 480×200). +- **Геометрия при смене режима** (без этого место режима не восстанавливалось точно): + 1. ставить через `XMoveResizeWindow`: кэш геометрии AWT после действий WM устаревает; + 2. сначала рамка и подсказки пропорций, потом размер. Пока WM меняет рамку, он игнорирует запрос размера — повторять раз в 60 мс, пока не совпадёт. Положение Muffin понимает как угол видимой рамки (заголовок 32 px); `StaticGravity` не помогает (AWT заменяет её на `NorthWestGravity`) — поправлять на разницу; + 3. при выходе из полного экрана Muffin сам возвращает размер «до полного экрана», позже нас — ждать, пока он снимет `FULLSCREEN` (~35 мс), и только потом ставить геометрию. +- **Размер под пропорции округляется** (например, 874×491,6 → 491 или 492) — при сравнении места допуск ±2 px. +- **Пересоздание окна при смене рамки** (отвергнутый путь) падало при переходе «окно → весь экран»: Compose пытался снять рамку у показанного окна (`IllegalComponentStateException: The frame is displayable`, следом SIGSEGV). После исправления (рамка меняется только вместе с пересозданием) работало, но обычное окно за каждый круг теряло 37 px высоты: запоминались внешние размеры AWT, а ставились как клиентские. +- **`toFront()` + `requestFocus()` в Muffin забирают фокус по-настоящему**, даже у окна фонового процесса (окно mpv, запущенное фоном, фокус не получало: Muffin не даёт его окнам без действия пользователя). Тестовое окно с фокусом перехватывает ввод в других программах — автоматические прогоны идут с окнами без фокуса (`SLEEPYOWL_TEST_PASSIVE=1`, `focusable = false`). +- **Перетаскивание через `_NET_WM_MOVERESIZE`:** отпустить захват мыши AWT (`XUngrabPointer` под `awtLock`), иначе WM не получит мышь; Compose после этого не залипает в «нажато» (синтетическое отпускание); передавать точку нажатия, а не текущую. +- **Боковая кнопка мыши «назад»:** X11 шлёт кнопку 8, AWT передаёт её как свою кнопку 6, а Compose кнопки после 5 отбрасывает. Ловится `AWTEventListener` на окне. +- **Русская раскладка:** AWT даёт буквам `keyCode = 0` (в Compose — `Key.Unknown`). Физическая клавиша берётся по коду X11 (`rawCode`): поле закрыто модулем `java.desktop`, у `AWTAccessor` геттера нет, но код есть в `KeyEvent.paramString()`. Проверено с `setxkbmap ru`: Ь→M, Ф→A, С→C, А→F. +- Переходы между режимами могут мелькать ~0,1 с: WM на мгновение показывает промежуточный размер. Виртуальный экран этого не показывает. +- Cinnamon может развернуть или «приклеить» окно, если тащить его к верхнему краю; запасной путь — двигать окно самим, без WM. + +### 5.4 Compose Desktop + +- **Клавиши получает только элемент в фокусе.** Клавиши «назад» — в `onKeyEvent` окна, а не модификатором на содержимом: на шаге без фокуса (выбор профиля) Esc иначе терялся. Окно получает всё, что не забрал никто. +- **Compose Desktop не двигает фокус стрелками** — только Tab. Стрелки, которые никто не взял, окно переводит в `moveFocus` само. +- **Фокус от наведения прокручивает ряд:** `clickable` при получении фокуса просит показать себя, и наведение на полускрытую карточку дёргало ряд. Отсюда своя `BringIntoViewSpec` на всё окно: прокручивать, только если последним был ввод с клавиатуры. +- Колесо над `LazyRow` уходит в вертикальную прокрутку страницы, Shift+колесо — в ряд: Compose делает это сам. +- **`KeyEvent.nativeKeyEvent` в CMP 1.12 — не событие AWT**, а внутренняя обёртка; событие AWT — через `awtEventOrNull`. +- **`awaitFirstDown` не ловит правую кнопку** — её берём из `onPointerEvent(Press)`. +- **«Движение мыши» без движения:** Compose шлёт его, когда под неподвижным курсором меняется раскладка (например, OSD затухает) — иначе управление показывало бы само себя снова. Движением считается только смена координат. +- **ViewModel'ям на ПК нужен `kotlinx-coroutines-swing`:** без него нет `Dispatchers.Main`, и `viewModelScope` падает при первом `launch`. + +### 5.5 Звук: PipeWire и WirePlumber + +- **WirePlumber запоминает «без звука» по роли потока**, а не по приложению (`~/.local/state/wireplumber/restore-stream`, запись вида `Output/Audio:media.role:Movie:mute=true`). mpv с видео помечает звук ролью Movie — однажды заглушённый поток (тестовый прогон с `mute=yes` через настоящий вывод или щелчок в микшере) глушит потом все видео через mpv, не только SleepyOwl. Лечится: включить звук у такого потока (WirePlumber запишет `mute=false`). Поэтому тестовый стенд и сквозная проверка запускают mpv с `ao=null` — в звуковую систему рабочего стола они не попадают вовсе. + +### 5.6 KMP, Koin, Gradle + +- **Если цели только android и jvm, `commonMain` компилируется как обычный JVM-код:** Kotlin пропускает компиляцию метаданных (`compileCommonMainKotlinMetadata SKIPPED`). В общем коде работают JDK (`File`, `ConcurrentHashMap`, `UUID`, `java.net`, `java.time`, `Locale`), OkHttp, SDK Jellyfin и ZXing как есть; expect/actual нужен только там, где Android API. Перевод ядра свёлся к ~15 файлам. С не-JVM целью (iOS) это кончится. +- Android-часть KMP-модуля на AGP 9 — плагин `com.android.kotlin.multiplatform.library`; обычные Android-ресурсы включаются `androidResources { enable = true }` в `android {}` (класс `R` — `ru.stenfredd.sleepyowl.ui.shared.R`). +- **`internal` не работает между модулями:** раскладки, `plural`, `PlayerLabels`, запись `ScreenFocus.lastKey` стали публичными; умный каст на свойство из другого модуля пришлось заменить локальной переменной. +- **R8 сливает объект `Log` с чужим классом** (в первой release-сборке все теги были `WebSocketProtocol`): свой кадр стека теперь отличается по имени класса во время работы, как у Timber. +- **Koin ловит пропущенную зависимость только при запуске** (Hilt — при сборке). Проверку возвращают тесты графа (`KoinGraphTest` на ТВ, `DesktopKoinGraphTest`, `DesktopGraphTest`). `verify()` читает конструктор каждого объявленного типа, даже если объект создаётся в лямбде, — `File`, `OkHttpClient.Builder` и (пока движков нет) `EngineProvider` для `getAll()` записываются в `extraTypes`. +- `Key.Menu` Compose на Android — тот же `KEYCODE_MENU`, поэтому `clickableWithMenu` общий без `nativeKeyCode`. +- **SDK Jellyfin на JVM не знает устройства:** на Android `createJellyfin` берёт `DeviceInfo` из системы, на ПК его надо задать (`JellyfinPlatform.configure`). Без него ввод адреса вручную падал с «Нет связи с сервером» (а поиск в LAN, идущий другим путём, работал). +- Старые данные ТВ читаются новым кодом: аккаунты в формате `org.json` (с `\/` и `null`), запомненные дорожки, история поиска. +- **`build.gradle.kts`:** имя `java` — это расширение Gradle, а не пакет: `java.nio.file.Files` не находится, нужен `import`. Время сборки для версии пакета берётся в действии задачи, а не при настройке: с кэшем конфигурации (`org.gradle.configuration-cache=true`) значение из настройки повторялось бы от сборки к сборке. + +### 5.7 D-Bus (dbus-java) + +- Свойства отдаются своим `Properties` (Get/GetAll/Set): `Get` возвращает `Variant`, тип в D-Bus — `v`. +- `Variant.equals` сравнивает массивы по ссылке — для «изменилось ли» сравнивать содержимое. +- Адрес обложки: `File.toURI()` даёт `file:/путь` (одна косая черта), а апплеты Cinnamon ждут `file:///путь` — использовать `toPath().toUri()`. + +### 5.8 Jellyfin + +- Сервер сбрасывает позицию, если она меньше 5 % длительности (настройка «минимум для продолжения»): 20 с просмотра с 5-й минуты фильма дают позицию 0. Это не ошибка отчётов. +- Если приложение убито без отчёта об остановке, сервер через несколько минут сам закрывает его сессию и записывает последнюю позицию — след в данных просмотра появляется с задержкой. +- `reportPlaybackProgress failed: JobCancellationException` при выходе из плеера — отменённый вместе с плеером промежуточный отчёт; отчёт об остановке идёт отдельно (`GlobalScope`) и доходит. Не ошибка. +- Вход на другом устройстве без набора пароля: код Quick Connect с его экрана подтверждается запросом `POST /QuickConnect/Authorize?code=…` с токеном уже вошедшего клиента (удобно для ТВ в проверках). +- В проверенной конфигурации сервер отдавал `static`-поток и внешние субтитры и без токена; клиент на это не полагается. + +## 6. mpv на медиатеке: 0.37 против 0.41 + +Перед кодом mpv 0.37 (пакет Mint, распакован без установки: `apt download mpv`, `dpkg -x`) и 0.41 (Flatpak `io.mpv.Mpv`) прогнаны на файлах, с которыми были проблемы на ТВ (`tools/linux/l0/`, §7.6). Каждый файл: прямая ссылка `static=true`, окно 640×360, звук выключен через настоящий вывод, старт с 40 %, 15 с воспроизведения, точная перемотка +300 с, ещё 10 с; сняты пропуски (`frame-drop`, `decoder-frame-drop`, `vo-delayed-frame`), `hwdec-current`, CPU, GPU и NVDEC (`nvidia-smi`), расхождение A/V, паузы на подгрузку, время до первого кадра и перемотки. Рендерер — `vo=gpu` на OpenGL (то, что умеет render API); для HDR/DV для сравнения ещё `gpu-next` 0.41. + +**Итог: на 0.37 нет проблем, которых нет на 0.41** → зависимость от системной `libmpv2`. Во всех 58 прогонах: ноль пропущенных кадров, без пауз на подгрузку, A/V ≤ 0,6 мс, первый кадр за 0,3–1,1 с, перемотка 0,1–0,6 с (MOV — 1,9 с). + +| Формат | Декодирование | Замечания | +|---|---|---| +| MKV H.264, 17 SRT, цепочка SeekHead (ломала перемотку Exo на ТВ) | nvdec | перемотка 0,1 с, проблемы ТВ нет | +| AVI XviD + AC3 (на ТВ рвался звук) | программно | CPU 3 %, A/V ≤ 0,4 мс; лишнего чтения, как у демуксера AVI в VLC, нет | +| AVI MPEG-4 ASP 2×AC3 / AVI H.264 + MP3 | программно / nvdec | | +| MPEG-PS 576i, нижнее поле первым | программно | без деинтерлейса гребёнка 25 к/с; `deinterlace=yes` → `bwdif`, 50 к/с, CPU 10 % | +| MKV H.264 1080i50 | nvdec | 0.37: `yadif_cuda`, CPU 3 %; Flatpak 0.41: `bwdif` на CPU, 20 % | +| DV P8.1 + HDR10+, 4K 24p | nvdec | базовый слой HDR10 → SDR, цвета нормальные; GPU 3 %, NVDEC 5 % | +| DV P8.1 1080p | nvdec | «Multiple Dolby Vision RPUs» безвредно | +| DV P8.4 (HLG) / HLG, 4K30 | nvdec | цвета нормальные | +| HEVC 10 бит 4K HDR10, 55 Мбит/с, TrueHD 7.1, ASS | nvdec | CPU 5 %, ASS рисуется | +| HDR10+ 4K | nvdec | цвета нормальные | +| AV1 10 бит 4K HDR10, TrueHD | nvdec | перемотка 0,5 с | +| AV1 1080p, Opus 5.1 | nvdec | | +| PGS | — | субтитры видны | +| 720p60 + ASS | nvdec | 60 к/с | +| внешние SRT (`sub-files-append` с адресом сервера) | — | кириллица верная | +| H.264 Hi10P | программно | NVDEC не умеет H.264 10 бит; CPU 19 % одного ядра | +| H.264 4K25; TS H.264 1080p; DTS 5.1 | nvdec | | +| MOV, mov_text | nvdec | перемотка 1,9 с — самая долгая | +| DV профиль 5 | — | в медиатеке нет; вопрос открыт (§5.1) | + +Проблемы ТВ на mpv не повторяются (перемотка MKV с цепочкой SeekHead, звук AC3 в AVI, звук DVD, PGS) — свои экстракторы и прокси на ПК действительно не нужны. Сеть при малом кэше ~5 Мбит/с для AVI 2,1 Мбит/с — так же, как у MKV. + +**Обычный mpv плавающим окном в Cinnamon** (`--ontop --no-border --geometry=640x360-24-24`) — эталон поведения мини-окна: `_NET_WM_STATE_ABOVE` ставится и снимается на лету (`set ontop`); рамка включается и выключается у живого окна через `_MOTIF_WM_HINTS` (Muffin добавляет/убирает заголовок 32 px) — отсюда способ смены рамки в приложении; весь экран — на мониторе с окном; окно фонового процесса фокус не получает. + +## 7. Тестирование + +### 7.1 Тесты без окна + +``` +./gradlew :desktop:test # DesktopGraphTest, ShellRenderTest, PlayerRenderTest, LicensesTest +./gradlew :ui:shared:desktopTest # DesktopKoinGraphTest, DesktopRenderTest +./gradlew :playback:engine-mpv:test # RenderSizeTest +./gradlew :app:testDebugUnitTest # граф Koin ТВ (KoinGraphTest) — если менялся общий код +``` + +- `DesktopGraphTest` — граф Koin ПК в том виде, в каком его запускает `main`; `DesktopKoinGraphTest` — граф общих модулей с платформой ПК. +- Тесты отрисовки рисуют без окна (`ImageComposeScene`) реализациями ПК и пишут PNG для просмотра глазами: + - `DesktopRenderTest` → `ui/shared/build/reports/ui-shared/components.png` — шрифты с кириллицей, логотип, иконки, свечение фокуса, BlurHash с затенением, QR, рамка; + - `ShellRenderTest` → `desktop/build/reports/desktop/shell-*.png` — меню на 1280 и 900 px; + - `PlayerRenderTest` → `desktop/build/reports/desktop/player-*.png` — три компоновки OSD, меню, карточка следующей серии. +- `RenderSizeTest` — размер FBO под видео; `EglProbe.kt` — ручная проверка контекста EGL рядом с окном Compose (§5.2). +- Если менялся общий код — ещё компиляция ТВ и `:app:testDebugUnitTest`. + +### 7.2 Тестовый стенд: Xvfb + Muffin со своей шиной D-Bus + +`tools/linux/stand.sh` поднимает виртуальный экран `:87` (Xvfb 1920×1080) с настоящим оконным менеджером Muffin и **своей сессионной шиной D-Bus** для Muffin и приложения. Окна, мышь, клавиши и фокус проверяются там — рабочий стол разработчика, его фокус, апплет звука, хранитель экрана и звук не затрагиваются. + +``` +stand.sh up | down поднять / остановить Xvfb, D-Bus стенда и Muffin +stand.sh app [args] (пере)запустить приложение на :87 в фоне; вывод — $STAND/app.out +stand.sh stop остановить приложение +stand.sh shot NAME снимок экрана → $STAND/shots/NAME.png +stand.sh in CMD… ввод через XTest (xinput.py): click X Y , move X Y , key NAME , type ТЕКСТ , drag X1 Y1 X2 Y2 +stand.sh log лог приложения +stand.sh bus CMD… команда на шине стенда (gdbus, busctl --user, dbus-monitor) +``` + +- **Свои файлы приложения:** каталог стенда `$STAND` (по умолчанию в `~/.cache`) — свои настройки и вход тестовой учётной записи, кэш, лог, блокировка; `~/.config/sleepyowl` разработчика не используется. +- **Звук:** mpv на стенде играет в `ao=null` (если `SLEEPYOWL_MPV_OPTIONS` не говорит иного) — §5.5. +- **Своя шина нужна** потому, что с MPRIS приложение стенда иначе появилось бы в апплете звука рабочего стола и держало бы его хранитель экрана. `tools/linux/fake_screensaver.py` — подставной `org.freedesktop.ScreenSaver` на шине стенда, печатает Inhibit/UnInhibit. +- Gradle собирает приложение перед `app`, если не задан `DESKTOP_NOBUILD=1`. `SLEEPYOWL_APP=<запускалка>` запускает её вместо сборки — например, пакет, распакованный `dpkg-deb -x sleepyowl_*.deb DIR` → `SLEEPYOWL_APP=DIR/opt/sleepyowl/bin/sleepyowl` (так же для `k2.py`). +- Полезные переменные приложения: `SLEEPYOWL_TEST_PASSIVE=1` — окна без фокуса, главное свёрнуто; `SLEEPYOWL_PLAYER_BOUNDS=x,y,w,h` — место окна плеера (замеры на настоящем экране); `SLEEPYOWL_SCALE`; `--play --start <с>`. + +**Ограничения стенда:** + +- Skiko на Xvfb рисует без GL — снимки интерфейса верные. +- Видео рисует программный GL llvmpipe, mpv при этом декодирует на NVIDIA (`nvdec-copy`). На 1080p успевает (~40 мс на кадр), на 4K HDR — нет (19 пропусков за 15 с). **Кадр на стенде бывает чёрным** при видимых субтитрах (H.264 1080i50 — постоянно, иногда другие файлы): llvmpipe не успевает за 40–50 мс на кадр. Тот же файл на настоящем GPU играет нормально. Поэтому картинку видео на стенде не проверяем — только состояние и журнал; автоскрытие OSD сравнивается по кадрам на паузе, а не по яркости. +- Нет Cinnamon и `csd-media-keys`: медиаклавиши, апплет звука и настоящий хранитель экрана проверяются только на рабочем столе. Мелькание при смене режима и плавность перетаскивания на высокой частоте экрана — тоже. +- После перезапуска приложение открывается на «Главной» — щелчки по координатам с прошлого запуска попадают в другие элементы (например, в карточки «Продолжить»). +- Приложение при запуске переименовывает прошлый лог в `sleepyowl.1.log` — проверки читают свежий. + +### 7.3 Сквозная проверка: `tools/linux/k2.py` + +Одна команда, ~5 мин, на стенде со своей шиной, без звука, с `nice`. Состояние плеера читается через MPRIS (статус, позиция, громкость, название), окна — через X11 (`_NET_WM_STATE`, место и размер, рамка), хранитель экрана — через `fake_screensaver.py`. Настройки плеера начинаются с нуля (первый запуск) и возвращаются после прогона, данные просмотра тестовой учётной записи на сервере возвращаются через `watchdata.py restore`. Печатает PASS/FAIL по каждой проверке, снимки — `$STAND/shots/k2-*.png`. Последний итог — 46/46, в том числе на распакованном .deb (урезанная JRE: mpv через JNA, D-Bus, режимы и клавиши работают так же, как из Gradle). + +Что проверяется: + +- первое открытие: мини 640×360 в правом нижнем углу с отступом 24 px, поверх, без рамки, с фокусом; MPRIS «Playing» с названием; запрет хранителя экрана; +- клавиши: пробел (пауза снимает запрет), →×3 = одна перемотка +30 с, Shift+← −60 с, ↓/↑, M, M в русской раскладке (физическая Ь), A ×2 (дорожка 2 → 1 → 2); +- автоскрытие: показ при наведении, скрытие через 2 с покоя в мини, снова при движении, сразу при уходе мыши; +- мини: перетаскивание за видео (без паузы), размер за правый край с 16:9; +- режимы: T → окно 1280×720 по центру с рамкой; F → весь экран; Esc → окно; T → мини на прежнем месте; двойной щелчок мини → окно → весь экран; фокус остаётся у плеера; +- меню правой кнопки (снимок); +- фокус в медиатеке: MPRIS Next → следующая серия в том же окне, фокус не забран; конец серии → следующая сама, фокус не забран; Previous, Seek, Pause, PlayPause; +- × закрывает плеер: имя MPRIS ушло, запрет снят, медиатека на месте и активна; перезапуск → мини на последнем месте и размере; ошибок в логе нет. + +Для прогона нужны три идущие подряд серии одного сезона (`K2_EPISODES`, §7.5), лучше с размеченными заставкой и титрами (`MediaSegments`). + +### 7.4 Данные просмотра: `tools/linux/watchdata.py` + +Тестовые прогоны двигают позиции и отметки на сервере. Скрипт снимает их до прогона и возвращает после: + +``` +watchdata.py snapshot [FILE] сохранить данные просмотра (позиция, просмотрено, счётчик, дата, избранное) +watchdata.py diff [FILE] что изменил прогон +watchdata.py restore [FILE] вернуть изменённое как в снимке +``` + +Файл по умолчанию — `$STAND/watchdata.json`. Токен берётся из сохранённых аккаунтов стенда (по имени `JF_USER`) или из `JF_TOKEN` + `JF_USER_ID`. `restore` пишет сохранённое `UserData` обратно (`POST /UserItems/{id}/UserData`); элемент, которого не было в снимке, сбрасывается (`DELETE /UserPlayedItems`, позиция и счётчик — 0). Порядок «Продолжить просмотр» следует `LastPlayedDate`, поэтому возвращается вместе с датами. `restore` — после того, как сервер закрыл все сессии прогона (§5.8), иначе след появится позже. + +### 7.5 Локальная конфигурация + +Скрипты не содержат адресов и учётных записей: всё личное они читают из локального файла `local/dev.env` (каталог `local/` не входит в git; шаблон — `tools/dev.env.example`). Формат — строки `KEY=value` без кавычек и подстановок, его читают и shell-скрипты (`tools/devenv.sh`), и Python; переменная, уже заданная в окружении, важнее файла. Переменные: + +- `JAVA_HOME` — JDK 21 для Gradle и приложения (иначе из окружения или `java` из `PATH`); `ADB` — adb из Android SDK; +- `JF_URL` — сервер Jellyfin для скриптов (по адресу в LAN); `JF_USER` — тестовая учётная запись на нём (прогоны меняют её данные просмотра, `watchdata.py` их возвращает); +- `TV_ADB` — адрес тестового ТВ для adb; +- `K2_EPISODES` — три идущие подряд серии одного сезона для `k2.py`; `L0_FLOAT_ITEM`, `L0_CASES` — для `tools/linux/l0/`; `PROFILE_SERIES` — сериал для генератора baseline profile ТВ. + +`JF_TOKEN` / `JF_USER_ID` в окружении — по желанию, вместо сохранённого аккаунта стенда. Для проверок лучше завести на сервере отдельную тестовую учётную запись — тогда прогоны не трогают «Продолжить просмотр» настоящих пользователей. + +### 7.6 Прочие скрипты + +- `tools/linux/desktop.sh` — запуск приложения без Gradle вокруг процесса. +- `tools/linux/l0/` — прогон mpv на файлах медиатеки (§6): `run_all.sh` по списку случаев (`L0_CASES`, строки «<имя> [опции run_case.py]»), `run_case.py` — один файл, `float_test.py` — поведение плавающего окна mpv (`L0_FLOAT_ITEM`). +- Замеры прототипа снимались его встроенным оверлеем статистики (кадры mpv и Compose, потерянные, время отрисовки и копии, CPU процесса, пропуски mpv) и разбивкой CPU по потокам из `/proc//task`. +- Опыт стенда: снимок всего экрана при полноэкранном окне идёт ~6 с — снимки делать в фоне и печатать опоздание шага; окно искать заново после пересоздания; убивать тестируемый процесс при любой ошибке сценария и ждать его выхода перед следующим тестом. + +### 7.7 Как проверяем + +- После каждого изменения — быстрая проверка (10–15 мин): сборка и тесты ПК, при изменении общего кода — компиляция ТВ и `:app:testDebugUnitTest`, короткий запуск на стенде со снимком. +- Перед выпуском — `k2.py` на стенде (в том числе на распакованном пакете), установка пакета, запуск из меню, значок, подъём окна повторным запуском, обновление новой сборкой. +- ТВ — короткая проверка, только если менялись версии Compose, Kotlin, AGP, Koin, Coil, корутин или общее поведение плеера (`PlaybackSession`, отчёты, пропуск заставок, «следующая серия», `PlayerViewModel`). +- Замеры CPU — на настоящем экране, окнами без фокуса (`SLEEPYOWL_TEST_PASSIVE=1`, `SLEEPYOWL_PLAYER_BOUNDS`), со звуком выключенным (`SLEEPYOWL_MPV_OPTIONS=mute=yes`) и с прогревом JIT. + +## 8. Не сделано / отложено + +- **Flatpak** (манифест с mpv из исходников, как у Jellyfin Desktop на Flathub) и **AppImage** — по желанию. +- **SIGTERM** не перехватывается: приложение, убитое сигналом, не отправляет отчёт об остановке (сервер закроет сессию сам через несколько минут) и может зависнуть на выходе (§5.2). Стоит перехватить и закрываться штатно. +- **Превью кадров на таймлайне** (trickplay) — когда на сервере будут данные. +- **HDR** — только если обойдётся без осложнений: отдельный режим «mpv в своём окне» при HDR-экране (KDE/GNOME на Wayland). +- **Сквозной звук на ресивер и смена частоты экрана** — для сценария «ПК к телевизору по HDMI». +- **Нативный Wayland** без XWayland — когда Compose Desktop его поддержит; «поверх всех» там придётся делать средствами конкретной оболочки. С выходом Cinnamon на Wayland проверить, держит ли Muffin такие окна поверх через XWayland. +- **Токены в libsecret** вместо файла. +- **Синхронизация выбора дорожек ТВ ↔ ПК** — нет: выбор запоминается на каждом устройстве свой. +- **Открытые вопросы:** цвета DV профиля 5 через render API (нет файла для проверки, §5.1); рывки 24p на весь экран при частоте экрана 60/144/175 Гц; мелькание ~0,1 с при смене режима окна (на стенде не видно); поведение Cinnamon при перетаскивании окна к верхнему краю. +- **Запасные варианты** на случай, если вывод видео в Compose перестанет устраивать: (а) mpv рисует в своё X11-окно, вставленное в наше (`--wid`), управление — отдельным прозрачным окном Compose, едущим вместе с ним (два окна при перетаскивании надо синхронизировать); (б) окно целиком принадлежит mpv (поверх всех, перетаскивание и размер mpv умеет сам), управление — скрипт Lua в mpv, приложение командует mpv через API (вид плеера будет отличаться от приложения). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8b4a63d --- /dev/null +++ b/docs/development.md @@ -0,0 +1,84 @@ +# Разработка + +## Сборка + +Нужны JDK 21 и Android SDK (compileSdk 37). Gradle — через обёртку `./gradlew` (9.8.1). + +| Что | Команда | +|---|---| +| ТВ, debug | `./gradlew :app:assembleDebug` → `app/build/outputs/apk/debug/app-debug.apk` | +| ТВ, release | `./gradlew :app:assembleRelease` — R8 без обфускации, пока подписан debug-ключом; ABI `armeabi-v7a` + `arm64-v8a` | +| ПК, запуск | `./gradlew :desktop:run` или `tools/linux/desktop.sh` (без Gradle вокруг процесса) | +| ПК, пакет | `./gradlew :desktop:packageDeb` → `desktop/build/deb/sleepyowl_<версия>_amd64.deb` (зависит от `libmpv2`) | +| Тесты | `./gradlew test` (в т.ч. рендер-тесты экранов ПК и `ui:shared` без дисплея) | + +Производительность интерфейса на ТВ мерить только на release-сборке (см. [ui-performance.md](ui-performance.md)). +Baseline profile лежит в `app/src/release/generated/baselineProfiles/` и снимается заново на ТВ (`gen_profile`, ниже). + +## Личные настройки инструментов: `local/` + +Папка `local/` целиком в `.gitignore`: там всё, что относится к конкретной машине, серверу и аккаунтам — +настройки инструментов, рабочие заметки, тестовые данные, резервные копии. В git она не попадает. + +Инструменты в `tools/` берут адреса и идентификаторы из `local/dev.env`. Шаблон с описанием каждой переменной — +[`tools/dev.env.example`](../tools/dev.env.example): + +```sh +mkdir -p local && cp tools/dev.env.example local/dev.env # и заполнить +``` + +Формат — строки `KEY=value` без кавычек и подстановок; переменная, уже заданная в окружении, важнее файла. +Shell-скрипты подключают файл через `tools/devenv.sh`, Python-скрипты — через `tools/devenv.py`. Если нужной +переменной нет, скрипт останавливается и говорит, какую задать. + +| Переменная | Для чего | +|---|---| +| `JAVA_HOME`, `ADB` | JDK 21 и adb, если не из окружения/`PATH` | +| `TV_ADB` | адрес ТВ для adb (`:5555`) | +| `JF_URL` | сервер Jellyfin по адресу в LAN | +| `JF_USER` | тестовый аккаунт на сервере: тестовые прогоны меняют его данные просмотра | +| `PROFILE_SERIES` | id сериала для прохода по странице сериала при съёмке baseline profile | +| `K2_EPISODES` | три подряд идущие серии одного сезона для `tools/linux/k2.py` | +| `L0_FLOAT_ITEM`, `L0_CASES` | файл и список файлов для проверок mpv в `tools/linux/l0/` | + +Debug-тур (`app/src/debug/assets/tour.json`, список файлов для обхода всех форматов из отладочного экрана) тоже +не в git: он собирается со своего сервера. + +## Инструменты + +### `tools/tv/` — тестовый ТВ по adb + +`source tools/tv/env.sh` даёт функции: + +- `install_debug`, `install_release` (release сразу компилируется по baseline profile); +- `play [exo|vlc]` — открыть карточку и играть (debug deep-link `--es item`, `--es engine`, `--el start <мс>`); +- `k KEY…` — нажатия пульта, `shot NAME` — скриншот в `$OUT`; +- `plog` — журнал решений сессии воспроизведения, `vproxy` — журнал локального прокси VLC, `aout` — что реально идёт на аудиовыход; +- `tv_token` — токен текущего аккаунта (только debug-сборка, через `run-as`), `sess` — что играет на ТВ по данным сервера; +- `ui_bench [right|down] [N]` — замер плавности (перцентили кадра и GPU, разбивка по фазам через `framestats.py`); +- `gen_profile` — снять baseline profile на ТВ (~8 мин, на ТВ должен быть выполнен вход). + +`tools/tv/run-case.sh [exo|vlc]` — прогон одного файла: скриншот и журнал. + +### `tools/tour/build_tour.py` + +Собирает debug-тур: минимальный набор файлов медиатеки, покрывающий все форматные признаки сервера +(контейнер, кодеки, HDR, частоты, чересстрочность, субтитры) и важные для выбора движка пары. + +### `tools/logo/make_assets.py` + +Все растровые логотипы (баннер ТВ, адаптивная иконка, сова и надпись в интерфейсе, иконки ПК 16…512) генерируются +из `design/logo/sleepyowl.png`. PNG не править руками — перезапускать скрипт. + +### `tools/linux/` — версия для ПК + +- `desktop.sh` — запуск приложения без Gradle (`DESKTOP_NOBUILD=1` — без сборки, `SLEEPYOWL_SCALE` — масштаб). +- `stand.sh` — тестовый стенд: свой виртуальный дисплей Xvfb `:87` с оконным менеджером Muffin и своей сессией + D-Bus, звук в `ao=null`. Окна, мышь, MPRIS и хранитель экрана проверяются, не трогая рабочий стол. + Файлы приложения стенда — отдельно от обычных (`$STAND`). +- `xinput.py` — ввод на дисплей стенда; `fake_screensaver.py` — заглушка `org.freedesktop.ScreenSaver` на шине стенда. +- `k2.py` — сквозная проверка плеера и интеграции с системой на стенде (PASS/FAIL по пунктам, скриншоты). +- `watchdata.py snapshot|diff|restore` — снимок данных просмотра тестового аккаунта до прогона и возврат после. +- `l0/` — проверка, как сам mpv играет файлы медиатеки (версии 0.37 и 0.41, `vo=gpu`/`gpu-next`), и поведение плавающего окна mpv. + +Подробности о стенде и его ограничениях — в [desktop.md](desktop.md). diff --git a/docs/launcher-feed.md b/docs/launcher-feed.md new file mode 100644 index 0000000..904caee --- /dev/null +++ b/docs/launcher-feed.md @@ -0,0 +1,162 @@ +# Ряд «Продолжить просмотр» для оболочки ТВ: контракт (версия 1) + +Своя домашняя оболочка Android TV (пакет `ru.stenfredd.tvhome`, отдельный проект) показывает ряд «Продолжить просмотр». Содержимое ряда присылает SleepyOwl (`ru.stenfredd.sleepyowl`). Документ описывает контракт между ними и то, как его выполняет приложение. Менять контракт можно только одновременно в обоих проектах. + +## 1. Модель: приложение само присылает список + +Приложение отправляет оболочке **готовый список карточек, когда он меняется**. Оболочка хранит его в файле и показывает; при нажатии Home она ничего не спрашивает и процесс приложения не будит. + +Почему не иначе: + +- **Провайдер в приложении, который опрашивает оболочка** — каждый возврат на главный экран поднимал бы процесс приложения (DI, OkHttp, десятки МБ, ~0,5 с процессора). Это противоречит смыслу лёгкой оболочки. +- **Стандартный Watch Next / «Play Next» (TvProvider)** — проверено на эталонном ТВ: сторонний лаунчер чужие записи не видит. Запись от другого пакета даёт оболочке 0 строк даже с `READ_EPG_DATA`; нужен `ACCESS_ALL_EPG_DATA`, а он `signature|privileged`. +- **Виджеты (AppWidget)** — RemoteViews: нет свечения и увеличения из макета, плохо с фокусом пульта, тяжелее. +- **Собственный вход оболочки в Jellyfin** — второй логин и только один заранее выбранный профиль. + +## 2. Вызов + +```kotlin +context.contentResolver.call( + Uri.parse("content://ru.stenfredd.tvhome.feed"), + "continue_watching", // метод + json, // arg: JSON-массив карточек строкой + null, // extras +) // → Bundle { "version" = 1 } +``` + +| Параметр | Значение | +|---|---| +| Authority провайдера | `ru.stenfredd.tvhome.feed` | +| Метод | `continue_watching` | +| `arg` | JSON-массив карточек (§3) строкой | +| `extras` | `null` | +| Ответ | `Bundle` с `"version" = 1` (версия контракта) | + +- **Пустой массив `[]` прячет ряд.** Отправлять при выходе из аккаунта и когда продолжать нечего. +- **Порядок в массиве = порядок в ряду.** Оболочка принимает до 20 карточек, показывает первые 5. Приложение шлёт 10 (запас). +- Вызов делать не на главном потоке. + +### 2.1 Доступ и подпись + +- Оболочка принимает вызов **только от приложения, подписанного тем же ключом**, что и она (`checkSignatures` по uid вызывающего). Чужой ключ → `SecurityException`. +- **Смена ключа подписи приложения** (например, переход с debug-ключа на release-ключ) отключит ряд. Тогда либо подписать оболочку тем же ключом, либо научить её проверять конкретный сертификат приложения. +- Вызовы из `adb shell` оболочка тоже принимает (для отладки, §5). + +### 2.2 Видимость пакетов + +На targetSdk 30+ без записи в манифесте вызывающего `call` падает с `IllegalArgumentException: Unknown authority`, как будто оболочки нет: + +```xml + + + +``` + +### 2.3 Ошибки + +| Ситуация | Что приходит | Что делать | +|---|---|---| +| Оболочки нет на устройстве (другой ТВ, эмулятор) | `IllegalArgumentException` | глотать молча; лучше заранее проверить `packageManager.resolveContentProvider(...) == null` и ничего не считать | +| Приложение подписано другим ключом | `SecurityException` | записать в лог | + +## 3. Карточка + +```json +[ + { + "id": "", + "title": "Сериал · S2 E5", + "subtitle": "осталось 12 мин", + "progress": 0.24, + "image": "http://<адрес-сервера-в-LAN>:8096/Items//Images/Primary?fillWidth=326&fillHeight=184&quality=90&tag=", + "intent": "intent:#Intent;component=ru.stenfredd.sleepyowl/.MainActivity;S.item=;B.play=true;l.start=<мс>;end" + } +] +``` + +| Поле | Обяз. | Смысл | +|---|---|---| +| `id` | да | id элемента Jellyfin; по нему оболочка удерживает фокус при обновлении ряда | +| `title` | да | одна строка, влезает ~28 символов (шрифт 19 px в карточке 326 px). «Сериал · S2 E5» или название фильма; название серии не влезет. У серии без номеров на сервере — только название сериала | +| `subtitle` | нет | «осталось 31 мин» у начатого, «следующая серия» у следующей. Оболочка сама добавляет перед ним название приложения («SleepyOwl · …») | +| `progress` | нет | 0..1; нет поля или `null` — без полоски (следующая серия) | +| `image` | нет | картинка 16:9 размером 326×184 px (§3.1) | +| `intent` | да | `Intent.toUri(Intent.URI_INTENT_SCHEME)` (§3.2) | + +### 3.1 Картинка + +- Оболочка качает её простым `HttpURLConnection` **без токена** и кладёт на диск с ключом по URL. Jellyfin отдаёт картинки без авторизации; HTTP в локальной сети у оболочки разрешён. +- Источник — как у широкой карточки приложения: кадр серии → фон → thumb. +- **Адрес должен быть уже переписан на LAN-адрес сервера**, если он известен (иначе — публичный адрес). В самом приложении адреса картинок строятся на публичном адресе и переводятся на LAN перехватчиком OkHttp (`ServerRouting`), а у оболочки такого перехватчика нет. +- **Параметр `tag` оставлять:** при смене картинки на сервере меняется URL, и оболочка скачает её заново. +- `fillWidth`/`fillHeight` сохраняют пропорции, поэтому сервер отдаёт 327–328×184 вместо 326×184; оболочка режет по центру, это незаметно. + +### 3.2 Intent + +- Оболочка разбирает строку через `Intent.parseUri` и запускает интент, **только если компонент или пакет — `ru.stenfredd.sleepyowl`**. Сама добавляет `FLAG_ACTIVITY_NEW_TASK` и снимает флаги выдачи прав на URI. +- Сборка: + + ```kotlin + Intent().setClassName("ru.stenfredd.sleepyowl", MainActivity::class.java.name) + .putExtra("item", id) // String, id элемента + .putExtra("play", true) // сразу воспроизведение + .putExtra("start", positionMs) // Long, место остановки; у следующей серии не передаётся + .putExtra("series", seriesId) // String, только у серии + .toUri(Intent.URI_INTENT_SCHEME) + ``` + +| Экстра | Тип | Смысл | +|---|---|---| +| `item` | String | элемент для воспроизведения | +| `play` | Boolean | `true` — сразу плеер | +| `start` | Long | позиция в мс. **Без неё плеер начнёт с нуля** (и элемент выпадет из «Продолжить просмотр»); у следующей серии без прогресса не нужна | +| `series` | String | id сериала: под плеером окажется страница сериала на этой серии, а не страница серии | + +**Поведение приложения при запуске с карточки:** сразу воспроизведение с места остановки (у карточки в фокусе оболочки кнопка ▶). Стек — «главная приложения → страница фильма (или страница сериала на этой серии) → плеер», так что «Назад» из плеера ведёт на страницу, а дальше на главную приложения. Запомненные для фильма или сериала дорожки звука и субтитров подхватываются. + +Требования к приёмной стороне (выполнены в `MainActivity`): + +- `android:launchMode="singleTask"` + `onNewIntent(intent)` → `setIntent(intent)` и передача цели в Compose. Без этого при открытом приложении запуск с `NEW_TASK` лишь поднимает старую задачу: цель читается один раз при создании, и открывается прежний экран. +- По новой цели стек сбрасывается так же, как при холодном старте. Открытый в этот момент плеер закрывается и отправляет позицию на сервер, как при обычном выходе. +- Пользователь не вошёл (экран входа) — цель игнорируется. Запуск без цели (иконка приложения) экран не меняет. + +## 4. Когда и что отправляет приложение + +Реализация — `LauncherFeed` в модуле `app` (интент ссылается на `MainActivity`, поэтому не в общих модулях). + +- **Старт:** из `Application.onCreate` одной корутиной на `Dispatchers.IO`, без работы на старте: ждёт восстановления сессии (`JellyfinSession.restored` — до него «нет сессии» не значит «вышли») и ещё 3 с, чтобы не мешать главной. Время показа главной от этого не меняется. +- **Пересчёт:** при старте процесса; при смене аккаунта (вход, переключение профиля, выход); по событиям изменения пользовательских данных (`SessionCache.invalidations`: остановка плеера, «просмотрено», избранное). Пачки событий схлопываются за ~1 с. +- **Содержимое:** сессии нет → `[]`; иначе `WatchRepository.continueWatching()` (тот же вызов и тот же ключ кэша, что у главной приложения, — лишних запросов нет) → первые 10 → JSON. Один тайтл — одна карточка: от сериала остаётся последняя по времени просмотра начатая серия; дальше — следующие серии сериалов без начатых. +- **Отправка — только если строка JSON отличается** от последней отправленной. +- **Ошибка сети — ничего не отправлять:** у оболочки остаётся прошлый список. +- **Выход из плеера кнопкой Home:** плеер на `ON_STOP` ставит паузу, отправляет позицию на сервер и сообщает об изменении данных → пересчёт → оболочка получает новую позицию через 1–2 с. + +### Ограничения + +- Если процесс приложения убит сразу после Home из плеера (до ответа сервера), у оболочки остаётся старая позиция до следующего запуска приложения. Отправлять поправленный список, не дожидаясь сервера, можно, но не сделано. +- Просмотр на других устройствах (телефон, веб) оболочка увидит только после следующего запуска приложения. Периодической фоновой работы (WorkManager) нет намеренно: на эталонном ТВ фоновая активность сознательно сведена к минимуму. + +## 5. Проверка + +```bash +# что сейчас лежит у оболочки (по источникам) +adb -s :5555 shell content call --uri content://ru.stenfredd.tvhome.feed --method dump + +# подставить список от имени приложения без самого приложения; +# JSON — только в --arg: в --extra утилита content не принимает двоеточия +adb -s :5555 shell 'content call --uri content://ru.stenfredd.tvhome.feed --method continue_watching --extra source:s:ru.stenfredd.sleepyowl --arg "[]"' +``` + +Сценарии: + +1. Запуск приложения → на главном экране ТВ появился ряд, `dump` совпадает с «Продолжить просмотр» в приложении. +2. Начать или досмотреть что-то, выйти кнопкой Home → карточка поднялась первой с новой позицией. +3. «Просмотрено» на элементе → он пропал из ряда. +4. Переключить профиль → ряд другого профиля; выйти из аккаунта → ряда нет; войти обратно → ряд вернулся. +5. Карточка при закрытом приложении → открылось нужное с места остановки; при открытом приложении на другом экране → тоже нужное; при открытом плеере → плеер закрылся с сохранением позиции, запустилось новое, «Назад» — страница фильма/сериала. +6. Время показа главной (`Home shown in` в логе) не выросло; `call` не на главном потоке. + +## 6. На будущее + +- Параллельно публиковать тот же список в стандартный Watch Next (`androidx.tvprovider`), чтобы ряд был и на главном экране Google TV. +- Код оболочки: `FeedProvider` (приём, проверка подписи, разбор, хранение, загрузка обложек) и обновление ряда в её `MainActivity`. diff --git a/docs/playback.md b/docs/playback.md new file mode 100644 index 0000000..59596e5 --- /dev/null +++ b/docs/playback.md @@ -0,0 +1,507 @@ +# Воспроизведение видео на Android TV: итоговые выводы + +Итог работы над воспроизведением в клиенте Jellyfin для Android TV (2026-10-12…14). Документ написан так, чтобы по нему можно было **повторить то же качество воспроизведения в другом приложении**. В нём: как устроен движок, какие правила выбора, что сломано в media3, libVLC и на телевизорах MediaTek и как это обходится, как тестировать. + +Исходное исследование, сделанное до кода, лежит в [`research/`](research/). Здесь только то, что подтвердилось или выяснилось на реальном устройстве. + +Код, на который ссылается документ, находится в модулях `playback/*` этого репозитория: +- `playback/api` — интерфейсы; +- `playback/core` — сессия, выбор движка, опрос устройства; +- `playback/engine-exo` — ExoPlayer; +- `playback/engine-vlc` — libVLC. + +--- + +## 0. Коротко: главные выводы + +1. **Гибрид из двух движков работает.** media3/ExoPlayer — основной (аппаратный MediaCodec, HDR10/HDR10+/HLG/Dolby Vision, passthrough). libVLC — запасной (программный ffmpeg). На тестовой медиатеке (12 671 файл, треть — AVI) **ни один файл не потребовал серверного транскода**. +2. **Выбор движка — автоматический, по «весу потерь»**, до запроса к серверу. Плюс два сторожа во время просмотра: «нет первого кадра за 60 с» и «застой». По их сигналу сессия переходит на следующий движок с той же позиции. +3. **Большинство проблем оказались не в выборе движка, а в мелочах**, которые не видны в документации: + - экстракторы media3 ломаются на реальных файлах: MKV с цепочкой SeekHead, AC3 в AVI, MPEG-2 в VOB без PTS, DVD private stream; + - libVLC не дружит с HTTPS через reverse proxy и перекачивает данные при каждом шаге назад; + - на ТВ многоканальный PCM беззвучен, а видеослой «улучшает» SDR-картинку; + - убитый посреди видео процесс оставляет мусор в видеоконвейере MTK, и тот лечится только перезагрузкой ТВ. +4. **Путь вывода видео — отдельное решение**, не равное выбору движка: + - SurfaceView (аппаратный видеослой) нужен для HDR, 4K, чересстрочного видео и частот 24/25/50 (ТВ сам переключает частоту панели); + - TextureView (через GPU) даёт более естественную SDR-картинку ≤1080p 30/60 к/с на Xiaomi: обходит постобработку ТВ. +5. **Dolby Vision**: DV-декодер с базовым слоем HDR10 работает, ТВ включает режим DV. Если DV-декодера нет или дисплей не DV — играть базовый слой (HEVC/AV1/AVC) через свой `MediaCodecSelector`. +6. **Звук**: passthrough всего, что ТВ принимает (AC3, E-AC3, JOC, TrueHD, DTS, DTS-HD, AC4, MAT — декодирует Dolby MS12 телевизора). Остальное — стерео PCM. Для устройств без passthrough — ffmpeg-аудиодекодер в media3. +7. **Сеть**: дома видео нужно брать с LAN-адреса сервера по HTTP, а не через HTTPS-hairpin на reverse proxy. Иначе 4K-ремукс на 60+ Мбит/с уходит в подзагрузку каждые 5–8 с. +8. **Память**: на 32-bit ТВ с кучей 192 МБ ExoPlayer по умолчанию копит до ~145 МБ буфера → OOM. Нужны `largeHeap` и ограничение буфера. + +--- + +## 1. Исходные условия + +### 1.1 Тестовое устройство + +| Параметр | Значение | +|---|---| +| ТВ | Xiaomi TV S Pro Mini LED 75 (`MiTV-MOEU1`), Google TV, Android 14 / API 34 | +| SoC | MediaTek MT9689 (`ro.hardware=mt5896`), 4×Cortex-A73, GPU Mali (слабый) | +| ABI | **только `armeabi-v7a`**, 32-bit userspace. APK обязан содержать 32-bit библиотеки | +| RAM | 2,4 ГБ, свободно ~870 МБ. Java-куча 192 МБ, с `largeHeap` 512 МБ | +| Дисплей | приложения рисуются в 1920×1080, видеоплоскость 4K. Android видит один режим 3840×2160@60. HDR: DV, HDR10, HDR10+, HLG | +| Видеодекодеры (рантайм) | AVC (включая **High10**), HEVC, VP9, AV1 — до 8K; MPEG-2, VC-1, MPEG-4 — до 1080p (MPEG-4 до 1344×1152). DV: P4, P5, P8, P9, P10. **P7 нет** | +| Звук | Dolby MS12. Passthrough на встроенные динамики: AC3, E-AC3, E-AC3 JOC, TrueHD, DTS, DTS-HD, AC4, Dolby MAT | + +### 1.2 Сервер и медиатека + +- Jellyfin 12.2, `jellyfin-sdk-kotlin` 1.9.2. +- Медиатека: 12 671 файл. MKV / AVI / MP4 = 6924 / 4207 / 1524. MPEG-4 ASP (DivX/Xvid) — 4182, H.264 Hi10P — 151, HEVC HDR — 42, DV P8 — 46, MPEG-2 (в т. ч. чересстрочный) — 14, ASS-субтитров — 2475. Подробно: [`research/library-profile.md`](research/library-profile.md). +- Сервер доступен по публичному адресу (`https://jellyfin.example.com`) через openresty (HTTP/1.1) и в LAN по `http://<адрес-сервера-в-LAN>:8096`. + +### 1.3 Версии библиотек + +| Библиотека | Версия | Зачем | +|---|---|---| +| `androidx.media3` | 1.11.1 | основной движок | +| `org.videolan.android:libvlc-all` | 3.7.7 (внутри libVLC 3.0.25) | запасной движок | +| `io.github.peerless2012:ass-media` | 0.5.1 | ASS/SSA в ExoPlayer через libass | +| `org.jellyfin.media3:media3-ffmpeg-decoder` | 1.9.0+1 | программное аудио (TrueHD, DTS…) в ExoPlayer, если нет passthrough | +| OkHttp | 5.5.0 | локальный прокси для libVLC | + +--- + +## 2. Архитектура + +### 2.1 Модули и интерфейсы + +``` +playback/api PlaybackEngine, EngineProvider, Verdict/Loss, DeviceCapabilities, Codecs + + интерфейсы-возможности: VideoPathSwitchable, DolbyVisionSwitchable, + SubtitleStyleable, AudioDelayCapable, SubtitleDelayCapable +playback/core DeviceCapabilitiesProbe, EngineSelector, PlaybackSession, PlaybackPreferences +playback/engine-exo ExoEngineProvider, ExoEngine, исправленные экстракторы +playback/engine-vlc VlcEngineProvider, VlcEngine, LoopbackProxy, RecentBytesCache +``` + +- **`EngineProvider`** — точка подключения движка. Регистрируется в модуле Koin своего движка (`bind EngineProvider::class`; до 2026-10-25 — Hilt `@IntoSet`). Новый движок (mpv, libVLC 4) — это новый модуль, ядро не меняется. Методы: + - `evaluate(source, selection, device) → Verdict` — может ли движок сыграть файл напрямую и чего это будет стоить; + - `deviceProfile(device)` — свой DeviceProfile для сервера; + - `create(device)` — экземпляр движка. +- **`Verdict`** = `Direct` | `Degraded(losses)` | `No(reasons)`. +- **`PlaybackEngine`** — узкий интерфейс: `load`, `play`, `pause`, `seekTo`, выбор дорожек, `isSeekable`, `state`/`events`/`stats` (Flow), `attachVideo(FrameLayout)`, `release`. Остальные возможности — отдельными интерфейсами, которые проверяются через `as?`. +- Дорожки адресуются **индексами Jellyfin `MediaStream`**. Каждый движок сам сопоставляет их со своими треками. Так выбор дорожки переживает смену движка. + +Почему не «движок как media3 `Player`»: в клиентах, которые так сделали (Findroid, Wholphin), обёртка над mpv полна `TODO()` и `UnsupportedOperationException`. Если нужен MediaSession, ставится один адаптер `SimpleBasePlayer` поверх своего интерфейса. + +### 2.2 Жизненный цикл воспроизведения (`PlaybackSession`) + +1. Выбор движка: `EngineSelector.rank(...)` — список движков по возрастанию суммарного веса потерь. +2. `POST /Items/{id}/PlaybackInfo` с DeviceProfile **выбранного движка**. Флаги: `enableDirectPlay/DirectStream/Transcoding = true`, `allowVideoStreamCopy/AudioStreamCopy = true`, `alwaysBurnInSubtitleWhenTranscoding = false`, выбранные индексы аудио и субтитров, `mediaSourceId`, `startTimeTicks`. +3. Если `supportsDirectPlay` — URL `/Videos/{id}/stream?static=true&mediaSourceId&playSessionId&deviceId&tag`. Иначе — `transcodingUrl`. Причины транскода (`TranscodeReasons` в URL) пишутся в лог и показываются в UI. +4. Внешние субтитры (`deliveryMethod = External`) передаются движку отдельными URL. +5. Запуск движка, отчёт `Playing`, `Progress` каждые 10 с, в конце `Stopped`. +6. Сторожа (§7): первый кадр, застой. По ошибке — следующий движок из ранжирования с той же позиции. Если упали все — транскод. +7. После `Stopped` вызывается `DELETE /Videos/ActiveEncodings` (чтобы сервер не держал ffmpeg) и событие «данные просмотра изменились» для UI. + +--- + +## 3. Выбор движка + +### 3.1 Веса потерь (`Loss`) + +| Потеря | Вес | Когда | +|---|---|---| +| `HDR_LOST` | 100 | HDR-файл, а движок или дисплей HDR не выведут | +| `SOFTWARE_DECODE_HEAVY` | 40 | программно >1080p, 10-bit или >30 к/с при потоке > 1280×720×50 пикс/с | +| `DV_TO_BASE_LAYER` | 30 | DV будет сыгран как HDR10/HLG базового слоя | +| `BROKEN_AUDIO_TIMING` | 30 | известная поломка синхронизации звука (сейчас не используется: AC3 в AVI исправлен, §5.5) | +| `DVD_COMBING` | 30 | чересстрочный MPEG-PS (DVD) в ExoPlayer: «гребёнка» на видеослое ТВ | +| `HDR_UNVERIFIED` | 25 | HDR через VLC с аппаратным декодером: формально выводится, не проверено | +| `SOFTWARE_DECODE` | 15 | программное декодирование ≤1080p | +| `NO_DEINTERLACE` | 5 | чересстрочное в ExoPlayer: деинтерлейсит видеослой ТВ, поэтому вес малый | + +- `Direct` = 0, `No` = 10 000 (такой движок ещё может играть серверный транскод). +- При равенстве выигрывает приоритет: Exo = 0, VLC = 10. + +### 3.2 Правила ExoPlayer (`ExoEngineProvider.evaluate`) + +- **Контейнеры:** mkv, webm, mp4, m4v, mov, ts, mpegts, avi, mpeg, ogg, flv, 3gp. Остальное (M2TS, ASF/WMV, ISO, BDMV, VIDEO_TS) → `No`. +- **Видео:** + - нет MIME для кодека → `No`; + - H.264 10-bit без аппаратного `AVCProfileHigh10` → `No` (media3 не умеет программно); + - нет аппаратного декодера под разрешение, но программный есть → `SOFTWARE_DECODE_HEAVY`, иначе `No`. +- **HDR/DV** (`evaluateRange`): + - DV-декодер «есть», только если `MediaCodecList` показывает профиль **и** дисплей сообщает `HDR_TYPE_DOLBY_VISION`; + - P5 (`DOVI`, без совместимого слоя) без DV-декодера → `No`; + - P7 (`DOVI_WITH_EL…`) без DV-декодера → `No`: media3 не откатывается на HEVC; + - P8.x / P9 / P10 без DV-декодера → `DV_TO_BASE_LAYER`, и если дисплей не умеет HDR10/HLG — ещё `HDR_LOST`; + - HDR10 / HDR10+ / HLG без поддержки дисплеем → `HDR_LOST`. +- **Чересстрочное:** `NO_DEINTERLACE`. Если к тому же контейнер mpeg/mpg/vob/mpegps — ещё и `DVD_COMBING` (§5.8). +- **Аудио:** passthrough, если кодек есть в `device.passthroughEncodings`; иначе аппаратный или системный декодер; иначе ffmpeg-расширение; иначе `No`. +- **Субтитры:** внешние PGS / VobSub / DVB → `No` (media3 их как внешние не умеет). + +### 3.3 Правила libVLC (`VlcEngineProvider.evaluate`) + +- Контейнеры и кодеки не ограничиваются: libVLC через ffmpeg открывает практически всё. +- Аппаратный декодер VLC используется **только для >1080p** (§6.4), поэтому всё ≤1080p считается программным: `SOFTWARE_DECODE` или `SOFTWARE_DECODE_HEAVY` (>1080p, 10-bit, или >30 к/с с учётом удвоения деинтерлейсом при потоке > 1280×720×50 пикс/с). +- DV → `DV_TO_BASE_LAYER`, P5 → ещё и `HDR_LOST`. HDR при аппаратном декодере → `HDR_UNVERIFIED`, при программном → `HDR_LOST`. + +### 3.4 Что получается на практике (авто) + +| Файл | Движок | Вывод | +|---|---|---| +| HEVC/AV1 4K HDR10/HDR10+/HLG, TrueHD Atmos, ASS | Exo | видеослой, passthrough | +| DV P8.1, DV + HDR10+, DV P8.4 (HLG) | Exo | видеослой, DV-декодер, ТВ в режиме Dolby Vision | +| H.264/HEVC SDR ≤1080p, 30/60 к/с | Exo | через GPU (только Xiaomi, §5.2) | +| SDR 23,976/24/25/50 к/с, SDR 4K | Exo | видеослой | +| AVI DivX/Xvid (+ AC3) | Exo | аппаратно, AC3 через исправленный экстрактор | +| H.264 Hi10P | Exo | аппаратно (на этом ТВ есть High10) | +| MPEG-2 чересстрочный в MKV | Exo | видеослой (ТВ деинтерлейсит) | +| MPEG-2 чересстрочный в VOB/MPEG-PS (DVD) | **VLC** | yadif | +| M2TS, WMV, MKV-zlib, внешние PGS/VobSub | **VLC** | — | +| Ничего не подошло / упали оба | транскод | HLS | + +### 3.5 Пересчёт при смене дорожки + +Когда пользователь меняет аудио или субтитры, ранжирование пересчитывается (`PlaybackSession.onSelectionChanged`). В авторежиме сессия переходит на движок, который стал **строго лучше**, кроме уже упавших в этой сессии. Если движок выбран вручную, он не меняется. + +--- + +## 4. Jellyfin: детали протокола + +- **Авторизация в 12.x:** `X-Emby-Token` **отклоняется (401)**. Работает только заголовок `Authorization: MediaBrowser Token="…", Client=…, Device=…, DeviceId=…, Version=…`. Там, где заголовок не передать (URL для libVLC), — query `ApiKey=`. +- `/Videos/{id}/stream?static=true` сервер отдаёт даже без токена (особенность Jellyfin). +- **SDK `jellyfin-sdk-kotlin` читает тело ответа в вызывающем потоке** → `NetworkOnMainThreadException`. Все вызовы SDK — на `Dispatchers.IO`. SDK требует slf4j в рантайме. +- **DeviceProfile отдельный для каждого движка** (`buildDeviceProfile` из SDK): + - Exo: перечислены контейнеры и кодеки, которые реально есть на устройстве; `codecProfile` для hevc/av1/vp9 с `VIDEO_RANGE_TYPE inCollection` (список HDR-типов, включая DV-варианты, только если есть DV-декодер; P7 — только если есть `DvheDtb`); для h264 без High10 — `VIDEO_BIT_DEPTH ≤ 8`. Транскод — HLS fMP4 (`mp4`), hevc/h264, aac/ac3/eac3. Субтитры — EMBED (srt, ass, pgs, dvdsub, dvbsub, vtt…) и EXTERNAL (srt, ass, vtt, ttml). + - VLC: `directPlayProfile` без ограничений, транскод — HLS `ts`, субтитры — всё, включая sup/idx/sub. + - `maxStreamingBitrate` = `maxStaticBitrate` = 400 Мбит/с, чтобы сервер не транскодировал по битрейту. +- **ASS по умолчанию — главная причина транскода у других клиентов**: если клиент не рисует ASS, сервер прожигает их в картинку (полный транскод 4K HDR с тонмаппингом → тусклая картинка). ASS нужно рендерить на клиенте и не включать `alwaysBurnInSubtitleWhenTranscoding`. +- **`/Videos/ActiveEncodings`** удалять после остановки транскода. +- **Встроенная обложка** (mjpeg/png как видеопоток, 499 файлов в медиатеке): «видеодорожка» — первый поток `Video` с кодеком не из mjpeg/png/bmp/gif/webp. Обложку пропускать и при сопоставлении индексов Jellyfin с номерами треков движка: Jellyfin нумерует потоки глобально (индекс ffprobe), движки — по типу. +- **Сервер помнит дорожки прошлого просмотра** (`UserData.AudioStreamIndex/SubtitleStreamIndex`, если в профиле включено «Запоминать выбор»). Для таких фильмов языковые настройки пользователя не действуют. +- **Транскод DV-файла в fMP4 ломает ExoPlayer** (`FragmentedMp4Extractor: Top bit not zero`). Не исправлено, см. §10. + +### 4.1 Сеть: LAN-адрес для видео + +- **Симптом:** 4K-ремукс (66 Мбит/с) в Exo уходил в подзагрузку каждые 5–8 с. +- **Причина:** ТВ ходил на публичный адрес `https://jellyfin.example.com`. Split DNS резолвил его в LAN-адрес, но запрос шёл через reverse proxy с TLS на слабом CPU ТВ. Exo получал 40–115 Мбит/с, прямой `http://<адрес-сервера-в-LAN>:8096` даёт ~240 Мбит/с. +- **Решение:** + - поиск сервера в LAN: UDP broadcast на порт 7359, сообщение `Who is JellyfinServer?`. Берётся IP отправителя и порт 8096, проверяется `/System/Info/Public` → тот же `Id`; + - поле `LocalAddress` сервера нельзя брать как есть: за прокси сервер отдаёт в нём публичное имя; + - найденный LAN-адрес запоминается в аккаунте. Один `OkHttpClient` с перехватчиком переписывает запросы на публичный адрес в LAN-адрес. Если LAN не ответил за 1,5 с — запрос уходит на публичный адрес, и запускается новый поиск; + - ключи кэша картинок строятся на публичном адресе, чтобы не меняться дома и вне дома. +- **Итог:** 0 подзагрузок. **Минус:** токен в URL видео идёт по LAN открытым HTTP. + +--- + +## 5. ExoPlayer (media3 1.11.1) + +### 5.1 Настройки плеера (`ExoEngine`) + +- `DefaultRenderersFactory`: + - `setEnableDecoderFallback(true)`; + - `setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)` — ffmpeg-аудио **после** MediaCodec, чтобы аппаратные декодеры и passthrough были главнее; + - свой `MediaCodecSelector` (§5.3). +- `DefaultTrackSelector`: + - `setTunnelingEnabled(false)` — tunneling ломает часть сценариев и не понадобился; + - `setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)`. +- **`DefaultLoadControl.setTargetBufferBytes` = ¼ кучи, не больше 64 МБ** + `setPrioritizeTimeOverSizeThresholds(false)`. По умолчанию media3 копит до ~145 МБ видео: при куче 192 МБ это OOM и тормоза от GC (§8). 64 МБ — это ~8 с 4K-ремукса 60 Мбит/с, по LAN хватает. +- В манифесте `android:largeHeap="true"`. +- **Ложные отказы `Tracks.Group.isSupported`**: без `allowExceedsCapabilities = true` дорожка помечается неподдерживаемой. Пример: HEVC Main10 L5.1 High tier 55 Мбит/с media3 считает `EXCEEDS_CAPABILITIES`, а декодер играет его корректно. Декодеры MTK заявляют возможности ниже реальных. +- Собственные экстракторы подключаются через свою `ExtractorsFactory` (`ExoExtractorsFactory`) в `DefaultMediaSourceFactory`. + +### 5.2 Вывод видео: SurfaceView или TextureView + +Это самое неочевидное открытие проекта. + +- **SurfaceView = аппаратный видеослой.** + - Только через него работает HDR/DV и выводится 4K: UI-поверхность приложения всего 1920×1080. + - ТВ сам переключает частоту панели под видео (23,976 → 119,88 Гц, 25 → 100 Гц), хотя Android видит единственный режим 60 Гц. Рывков 24p нет. + - ТВ деинтерлейсит чересстрочное видео. + - **Но** на видеослой ТВ накладывает свою постобработку (резкость, контраст MediaTek PQ). Сжатое SDR-видео выглядит «жёстче», артефакты сжатия заметнее. Метаданные цвета ExoPlayer читает правильно (BT.709 limited), дело не в них. +- **TextureView = через GPU**, как обычная графика (так выводит VLC). + - Постобработки ТВ нет, SDR-картинка «мягче и естественнее» — проверено на глаз A/B-сравнением. + - Нет HDR, нет 4K (UI 1080p), нет деинтерлейса, частота UI 60 Гц → 3:2 judder на 24p и неровно на 25p. + - 4K HDR через GPU тормозит. +- **Правило AUTO (`ExoEngine`):** через GPU идёт только SDR, не чересстрочное, ≤1920×1088, с частотой, которая ровно делится в 60 Гц (`fitsUiRefreshRate`: 30, 60, 59,94). Всё остальное — видеослой. И только на ТВ Xiaomi (`Build.MANUFACTURER == "Xiaomi"` и `UI_MODE_TYPE_TELEVISION`); на других устройствах всегда видеослой: постобработка — особенность этого ТВ. +- Режим вывода хранится **в экземпляре движка**, а не статически. При переходе на другой движок внутри одного воспроизведения он переносится. Новый фильм начинается с AUTO или с настройки пользователя. Ошибка, которую мы сделали: статический режим «через GPU» перетекал на следующие файлы, и 4K шёл через GPU. +- Пользователь может переключить режим вручную (Авто / Видеослой ТВ / Через GPU) в «Информации о потоке → Дополнительно» и в настройках. + +### 5.3 Dolby Vision + +- media3 распознаёт DV в MKV (`BlockAdditionMapping` с `dvcC`/`dvvC`) и в MP4 и выбирает декодер `video/dolby-vision`. +- **Свой `MediaCodecSelector`.** Если он для `video/dolby-vision` возвращает пустой список, media3 через `getAlternativeCodecMimeType` берёт декодер базового слоя (HEVC/AVC/AV1). Это самый простой способ «выключить DV» и играть P8/P9/P10 как HDR10/HLG/SDR. P5 базового слоя не имеет, поэтому для него DV-декодер нужен всегда (`requiresDolbyVisionDecoder`). +- **На тестовом ТВ DV-декодер `c2.mtk.dvhe.st.decoder` работает:** ТВ показывает значок Dolby Vision, DispService пишет `Hdr Dolby`. Поэтому DV-декодер включён по умолчанию, если дисплей сообщает `HDR_TYPE_DOLBY_VISION`. +- Сначала картинка через DV-декодер казалась «тусклее», чем базовый слой HDR10. Оказалось, это режим изображения Dolby Vision в настройках ТВ, а не поломка. При сравнении учитывать режимы изображения ТВ. +- **DV + HDR10+** (известный баг media3 на MediaTek, #1895) на этом ТВ не воспроизвёлся: играет и через DV-декодер, и базовым слоем. +- **DV P7 (BD-remux)**: на этом ТВ в рантайме декодера нет. media3 откат на HEVC для P7 явно запрещает (PR #3160 не смержен). Нужно переписать формат дорожки на HEVC (декодер HEVC игнорирует NAL 62/63) — в медиатеке P7 нет, вслепую не делали. До тех пор P7 без DV-декодера → VLC (базовый слой). +- Выбор «DV-декодер / базовый слой» — переключатель в UI и настройках, показывается только для DV-файлов и только если у ТВ есть DV-декодер и DV-экран. + +### 5.4 HDR10 / HDR10+ / HLG + +- Работает «из коробки» через SurfaceView: статические метаданные идут в `KEY_HDR_STATIC_INFO`, HDR10+ для HEVC/AV1 — внутри потока. Встроенный ТВ включает HDR-режим сам по метаданным декодера. +- Через TextureView HDR не выводится никогда — поэтому HDR всегда на видеослое. +- `screencap` HDR/DV-видеослой не захватывает (чёрное). SDR — захватывает и через GPU, и (для AVC) через видеослой. Оценку HDR-картинки даёт только человек. + +### 5.5 Исправленные экстракторы media3 + +Все подключаются через `ExoExtractorsFactory` и проверены на реальных файлах. При обновлении media3 их нужно перепроверять. + +| Проблема | Симптом | Причина | Исправление | +|---|---|---|---| +| **MKV с цепочкой SeekHead** (после mkvpropedit/MakeMKV) | файл «неперематываемый», seek откатывает к началу | первый `SeekHead` ссылается только на второй `SeekHead` в конце файла, а уже тот — на `Cues`. `MatroskaExtractor` по цепочке не идёт → `SeekMap.Unseekable` | `SeekHeadChainExtractor`: через peek читает первую SeekHead; если в ней только ссылка на SeekHead — `RESULT_SEEK` к хвостовой, берёт позицию `Cues`, возвращается на 0 и на лету подменяет в первой SeekHead ID и позицию (`PatchingInput`). Обычные файлы не получают лишних запросов | +| **AC3/E-AC3/DTS в AVI** | рывки, постоянная буферизация, 173 пропуска за 16 с | AVI-куски по 1280 байт не совпадают с кадрами AC3 (1024 байта). `AviExtractor` отдаёт кусок как кадр, аудиосинк считает 32 мс вместо 40 → `UnexpectedDiscontinuityException` | `AviAudioFramingExtractor`: режет поток на настоящие кадры. Время кадра = время первого куска после старта/перемотки + байты × длительность кадра / размер кадра (время куска у `AviExtractor` в начале файла расходится на 0,5 с) | +| **MPEG-2 в VOB/MPEG-PS** | ~1–1,5 к/с, при этом счётчик пропусков молчит | `PsExtractor` даёт PES без PTS время **0**, а не «неизвестно»; `H262Reader` достраивает времена «предыдущее + кадр» в порядке декодирования. 14 из 15 кадров GOP получали 0, рендерер их **пропускал как «до старта»** (skipped, не dropped) | `Mpeg2TimestampFixExtractor`: читает `temporal_reference` из заголовка картинки и ставит время `ключевой + (tr − tr ключевого) × длительность кадра`. Частота кадров — из `frame_rate_code` в CSD | +| **DVD private stream 1 (0xBD)** | «квакающий» звук через passthrough | `PsExtractor` отдаёт 0xBD в `Ac3Reader` как есть: с 4-байтовым заголовком подпотока и со всеми подпотоками (субтитры, DTS, LPCM, вторая AC3) | Форк `PsExtractor`/`PsBinarySearchSeeker`/`PsDurationReader` (Apache 2.0, `engine-exo/src/main/java/.../exo/ps/`): `DvdPsExtractor` срезает заголовок подпотока, каждому AC3-подпотоку (0x80–0x87) даёт свой `Ac3Reader` и дорожку, ищет дорожки в окне 1 МБ. PES без PTS → `C.TIME_UNSET` | + +Урок: **для диагностики нужен счётчик «кадров/с на экране» и «пропущено декодером»** (`DecoderCounters.skippedOutputBufferCount`), а не только «пропущено кадров» (dropped). Проблему MPEG-PS поймал именно он. + +### 5.6 ASS/SSA + +- `ass-media` 0.5.1 (libass), `AssRenderType.OVERLAY_OPEN_GL`, кэш 32 МБ, рендер не больше 1080p. +- `AssSubtitleView` кладётся **внутрь `AspectRatioFrameLayout`**, чтобы раскладка шла относительно кадра, а не экрана. +- `AssMatroskaExtractor` библиотеки нужно обернуть в `SeekHeadChainExtractor`, иначе теряется исправление перемотки. Внешние ASS — через `setSubtitleParserFactory`. +- Библиотека собрана под media3 1.8.0 и лезет рефлексией в приватные поля `MatroskaExtractor` (`extractorOutput`, `subtitleSample`): при обновлении media3 проверять, при R8 нужны keep-правила. +- Проверено: 720p60 со стилизованными ASS — 60 к/с, 0 пропусков. После этого ASS-файлы остаются в Exo, VLC для них не нужен. + +### 5.7 Звук + +- **Passthrough** определяется через `AudioTrack.isDirectPlaybackSupported` для каждого `ENCODING_*` (`DeviceCapabilitiesProbe`). На встроенных динамиках Xiaomi passthrough проходит для AC3, E-AC3, JOC, TrueHD, DTS, DTS-HD, AC4, MAT — ТВ декодирует их сам (Dolby MS12). Исследование ожидало, что TrueHD и DTS-HD «из приложения не пройдут», — на этом ТВ проходят. +- **ffmpeg-аудио** (`media3-ffmpeg-decoder`) нужен для устройств без passthrough (телефоны, саундбары без декодера). С media3 1.11.1 работает (TrueHD и DTS декодировались при принудительном PREFER). APK вырос на ~40 МБ → нужны ABI-сплиты. +- Многоканальный PCM ExoPlayer сам направляет правильно (в отличие от VLC, §6.3). + +### 5.8 Чересстрочное видео + +- MPEG-2 декодирует аппаратный `c2.mtk.mpeg2.decoder`, а на видеослое ТВ деинтерлейсит сам (DispService: `interlace 1, scanType 1`). Через GPU видна «гребёнка», поэтому чересстрочное в AUTO всегда идёт на видеослой. +- **Исключение — DVD (MPEG-PS) с нижним полем первым**: гребёнка в движении остаётся и на видеослое. MKV с верхним полем первым — чисто. Jellyfin порядок полей не сообщает, поэтому правило сделано по контейнеру (`DVD_COMBING` → VLC с yadif). Точнее было бы читать `top_field_first` из picture coding extension. + +### 5.9 Частота кадров (24p/25p) + +- На этом ТВ ничего делать не нужно, если видео идёт через видеослой: ТВ сам переключает панель (проверено по DispService `PanelTiming`/`outFmt`). +- Через GPU — 60 Гц UI, отсюда правило AUTO из §5.2. +- На ТВ-приставках с HDMI нужно самим ставить `preferredDisplayModeId` и ждать `onDisplayChanged`: media3 сам умеет только бесшовную смену через `Surface.setFrameRate` (см. исследование [`research/media3.md`](research/media3.md) §3). + +### 5.10 Стиль субтитров и диагностика + +- Стиль текстовых субтитров (размер, цвет, фон) — через `SubtitleView` на лету. Пока виден OSD, субтитры поднимаются над ним. +- `AnalyticsListener` для статистики: декодер, кадры/с на экране, пропущено, пропущено декодером, срывы звука (`onAudioUnderrun`), число буферизаций (после первых 3 с от загрузки или перемотки). + +--- + +## 6. libVLC (3.7.7 / libVLC 3.0.25) + +### 6.1 Опции + +``` +--network-caching=3000 +--audio-time-stretch +--no-sub-autodetect-file +--deinterlace=-1 # авто: только для кадров, помеченных interlaced +--deinterlace-mode=yadif +-v # -vv для отладки: в logcat видны полные HTTP-заголовки +``` + +Плюс опции freetype для стиля субтитров (§6.6). + +### 6.2 Сеть: локальный прокси (`LoopbackProxy`) — обязательно + +**Проблема.** libVLC ходит по HTTPS через GnuTLS. Через reverse proxy (openresty, HTTP/1.1): +- после Range-запроса к концу MKV (чтение индекса) — «connection failed»; +- перемотка к концу файла ждёт ~10 с; +- иногда prefetch уходит в бесконечный цикл: 97 % CPU, >1 ГБ памяти; +- `--http-reconnect` делает только хуже; +- напрямую по `http://<адрес-сервера-в-LAN>:8096`, без reverse proxy, зацикливание не воспроизводилось. + +Рассматривались и другие пути: HTTP/2 на reverse proxy, старый http-модуль VLC. + +Произвольных HTTP-заголовков libVLC не умеет, поэтому токен можно передать только в URL. + +**Решение — HTTP-прокси внутри приложения на OkHttp.** +- VLC ходит только на `http://127.0.0.1://<путь сервера>?`. +- Прокси повторяет запрос через 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` его не видит. diff --git a/docs/research/README.md b/docs/research/README.md new file mode 100644 index 0000000..551f48b --- /dev/null +++ b/docs/research/README.md @@ -0,0 +1,61 @@ +# Исследование движков воспроизведения (начало проекта, 2026-10-11…12) + +Это исследование сделано **до** написания кода: по исходникам, issue-трекерам, Maven и дампу тестового ТВ. Часть выводов потом подтвердилась на реальном телевизоре, часть оказалась неверной. Что изменилось после тестов, сказано в конце этого файла, а итог описан в [`../playback.md`](../playback.md). + +## Файлы + +| Файл | О чём | +|---|---| +| [`decisions.md`](decisions.md) | Принятые решения: гибридный движок, выбор движка, fallback, тестовый ТВ, что проверить прототипом (часть 7 общего [`jellyfin-client-research.md`](jellyfin-client-research.md)) | +| [`media3.md`](media3.md) | media3/ExoPlayer: Dolby Vision, HDR, tunneling, частота дисплея, passthrough, FFmpeg, ASS (libass), контейнеры, DeviceProfile из jellyfin-androidtv | +| [`engines.md`](engines.md) | Как устроены несколько движков в других клиентах (Findroid, Wholphin, JellyPlay, Swiftfin, Plezy, Kodi…), дизайн абстракции, Gradle-модули, libVLC против libmpv, алгоритм выбора, runtime-fallback | +| [`device-xiaomi.md`](device-xiaomi.md) | Тестовый ТВ Xiaomi S Pro Mini LED 75 и семейство MediaTek: известные баги декодеров, DV, passthrough, чек-лист проверок | +| [`library-profile.md`](library-profile.md) | Профиль медиатеки тестового сервера: какие контейнеры, кодеки, HDR, аудио и субтитры встречаются и сколько | +| [`tv-dump/`](tv-dump/) | Сырые дампы ТВ: `media_codecs-mt5896.xml`, `dumpsys display`, `audio_policy` | + +Полный исходный документ со всеми частями (Jellyfin API, обзор клиентов, libVLC, Android TV, инструменты): [`jellyfin-client-research.md`](jellyfin-client-research.md). + +## Главное из исследования + +**Цель** — воспроизводить всё, что можно, без серверного транскода и с правильным HDR и Dolby Vision. Официальный клиент Jellyfin, Moonfin и Dune на этом ТВ спотыкались каждый на своих файлах: транскодировали, давали тусклый HDR или зависали. + +1. **Одного движка недостаточно.** + - media3/ExoPlayer — единственный путь к аппаратному декодированию с HDR10, HDR10+, HLG и DV, а также к passthrough. Но он не открывает M2TS, ASF/WMV, ISO/BDMV, MKV со сжатием zlib, не умеет программно H.264 Hi10P, VC-1, MPEG-4 ASP и не рисует ASS сам. + - libVLC и libmpv играют почти всё через ffmpeg, но HDR у них ненадёжен. + - Отсюда **гибрид: media3 основной, libVLC запасной.** +2. **Запасным выбран libVLC 3.7.x, а не libmpv.** + - Это официальный артефакт VideoLAN со стабильным Java API, внутри libass, PGS и VobSub, есть passthrough. + - Стоковый mpv-android переводит HDR в SDR через GLES, а AAR у него неофициальный. + - Цена libVLC — +24–55 МБ на ABI. +3. **Автоматический выбор движка до запроса PlaybackInfo.** Данные файла от Jellyfin (контейнер, кодек, битность, `VideoRangeType`, профиль DV, дорожки) сравниваются с возможностями устройства (`MediaCodecList`, HDR-типы дисплея, `AudioTrack.isDirectPlaybackSupported`). У каждого движка свой DeviceProfile. +4. **Runtime-fallback по образцу Plezy.** Если движок упал, завис или не дал кадра, сессия переходит на другой движок с той же позиции; если не справился и он — серверный транскод. Среди Jellyfin-клиентов такого нет ни у кого. +5. **Архитектура:** + - свой узкий интерфейс `PlaybackEngine` плюс интерфейсы-возможности (задержка звука, стиль субтитров и т. п.); + - каждый движок — отдельный Gradle-модуль, подключается через Hilt `@IntoSet` (позже DI перевели на Koin); + - реализация движка «как media3 `Player`» (Findroid, Wholphin) приводит к десяткам `TODO()`, поэтому так не делаем. +6. **Риски, найденные заранее:** + - DV P7 в media3 без DV-декодера не откатывается на HEVC; + - DV + HDR10+ зависает на MediaTek (#1895); + - HEVC-декодер MTK падает посреди потока (#3421), и `setEnableDecoderFallback` это не ловит; + - вечная буферизация при AC3 passthrough после быстрых перемоток (#3407); + - TrueHD и DTS-HD из приложения на встроенных динамиках, скорее всего, не выйдут в passthrough; + - смену частоты и HDR-режима media3 сам не делает. +7. **Тестовый ТВ** (дамп через adb): + - MediaTek MT9689, Android 14, **только 32-bit userspace (armeabi-v7a)**, 2,4 ГБ RAM; + - один режим дисплея 4K@60; + - дисплей заявляет DV, HDR10, HDR10+ и HLG; + - аппаратные AVC/HEVC/VP9/AV1 до 8K, MPEG-2/VC-1/MPEG-4 до 1080p. + +## Что тесты потом опровергли или уточнили + +| Ожидание из исследования | Как оказалось на ТВ | +|---|---| +| DV P7 есть в железе (по XML-дампу) | В рантайме `MediaCodecList` **не показывает** `dvhe.dtb` (P7). Наличие в XML не гарантирует наличие в API | +| H.264 Hi10P — только программно (libVLC) | `c2.mtk.avc.decoder` заявляет High10 и **играет Hi10P аппаратно** | +| DV + HDR10+ виснет на MediaTek (#1895) | На этом ТВ играет без зависаний, и с DV-декодером, и как базовый слой | +| DV-декодер даёт «тусклую» картинку | ТВ действительно включает режим Dolby Vision (значок, DispService `Hdr Dolby`). «Тускло» — это режим изображения DV в настройках ТВ, а не поломка. DV-декодер включён по умолчанию | +| Смена частоты нужна вручную (`preferredDisplayModeId`) | Android видит один режим 4K@60, но **на аппаратном видеослое ТВ сам переключает панель**: 23,976 → 119,88 Гц, 25 → 100 Гц. Через GPU-путь (TextureView) этого нет | +| HTTPS для libVLC достаточно проверить на самоподписанном сертификате | libVLC (GnuTLS) через reverse proxy ненадёжен даже с нормальным сертификатом: зацикливается prefetch, перемотка к концу файла ждёт по ~10 с. Решено локальным HTTP-прокси внутри приложения | +| `VLCVideoLayout` и libVLC — вопрос API | Главные проблемы VLC оказались в звуке (многоканальный PCM на этом ТВ беззвучен) и в сетевом кэше (prefetch выбрасывает прочитанное) | +| VC-1 через media3, если есть декодер | Декодер зарегистрирован как MIME `video/VC1` (регистр!), а media3 ищет `video/wvc1` | +| Аппаратное декодирование в libVLC — по умолчанию | Опрос MediaCodec модулем VLC на MediaTek длится ~45 с. Аппаратно в VLC включаем только для видео больше 1080p | diff --git a/docs/research/decisions.md b/docs/research/decisions.md new file mode 100644 index 0000000..3d7c0cb --- /dev/null +++ b/docs/research/decisions.md @@ -0,0 +1,55 @@ +# Часть 7. Гибридный движок воспроизведения (дополнение от 2026-10-12) + +Цель проекта уточнена: не «клиент на libVLC», а **клиент, который воспроизводит всё возможное без серверного транскода и с корректным HDR/Dolby Vision**. Полные отчёты: [media3.md](media3.md), [engines.md](engines.md), [device-xiaomi.md](device-xiaomi.md); сырые дампы тестового ТВ — [tv-dump/](tv-dump/). + +## 7.1 Принятые решения + +| Вопрос | Решение | +|---|---| +| Движок | Гибрид: **media3/ExoPlayer — основной** (аппаратный MediaCodec, HDR10/HDR10+/HLG/DV, passthrough), **libVLC 3.7.7 — запасной** (программный ffmpeg для того, что не тянет железо/media3). mpv и libVLC 4 — возможные будущие модули. | +| Расширяемость | Свой узкий интерфейс `PlaybackEngine` + capability-интерфейсы (AudioDelay, SubtitleDelay, Passthrough, SubtitleStyle…); каждый движок — отдельный Gradle-модуль, регистрируется через Hilt `@IntoSet`; один адаптер `SimpleBasePlayer` в ядре для MediaSession/UI. Треки адресуются индексами Jellyfin `MediaStream`. | +| Выбор движка | Автоматический, **до** `PlaybackInfo`: данные файла (контейнер, кодек, профиль, битность, `VideoRangeType`, DV-профиль, субтитры, аудио) × возможности устройства (`MediaCodecList`, HDR-типы дисплея, `AudioCapabilities`, `RendererCapabilities` media3). Порядок: ручной выбор → media3 Direct → вариант с наименьшими потерями (HDR важнее всего) → серверный транскод. DeviceProfile — свой для каждого движка. | +| Runtime-fallback | Как в Plezy: при ошибке декодера / зависании / 0 кадров — один переход на другой движок с сохранением позиции и треков; если упал и он — транскод. Ни один Jellyfin-клиент этого не делает — это ключевое отличие проекта. | +| Сервер / SDK | Только Jellyfin 12.x, `jellyfin-sdk-kotlin` 1.9.x. | +| Платформа | Сначала Android TV, общие модули для будущего phone-UI. | +| DI / лицензия | Hilt (позже заменён на Koin); GPL-3.0-or-later; собственное название без Jellyfin-бренда. | +| Сборки | `armeabi-v7a` обязателен (тестовый ТВ — 32-bit userspace), сплиты по ABI; flavors `full` (Exo+VLC) / `lite` (только Exo). Dynamic Feature Delivery не подходит (sideload/F-Droid). | + +## 7.2 media3: что играет и что нет (кратко) + +- **Да:** HDR10/HDR10+/HLG, DV в MKV и MP4 (`dvcC`/`dvvC`), passthrough AC3/E-AC3/JOC/TrueHD/DTS/DTS-HD, ASS через `io.github.peerless2012:ass-media` (libass), PGS/VobSub/DVB внутри MKV, AVI. Вывод только через `SurfaceView` (Compose `PlayerSurface` с `SURFACE_TYPE_SURFACE_VIEW`). +- **Нет (→ libVLC):** M2TS, ASF/WMV, ISO/VIDEO_TS/BDMV, MKV с zlib, ordered chapters; H.264 Hi10P; VC-1/MPEG-2/MPEG-4 ASP без HW-декодера; TrueHD/PGS/VC-1 в TS; внешние `.sup`/`.idx`. FFmpeg-расширение media3 — только аудио (`org.jellyfin.media3:media3-ffmpeg-decoder`). +- **Риски:** DV P7 без DV-декодера не имеет фолбэка на HEVC (нужен свой `MediaCodecSelector` или `DOVI_WITH_EL` как неподдерживаемый); DV P8.1+HDR10+ виснет на MediaTek (#1895); падения HEVC-декодера MTK посреди потока не ловятся `setEnableDecoderFallback`; смену частоты/HDR-режима дисплея делать самим (`preferredDisplayModeId` + ожидание `onDisplayChanged`); все PGS-дорожки декодируются даже невыбранные (#2667, память). Tunneling — опция, по умолчанию выкл. +- jellyfin-androidtv объявляет в direct play ASF/WMV/VOB, которые media3 не открывает, — вероятный источник ошибок официального клиента. + +## 7.3 libVLC vs libmpv как запасной + +libVLC 3.7.7 выбран: официальный артефакт VideoLAN с регулярными релизами, стабильный Java API, libass/PGS/VobSub внутри, passthrough, выравнивание 16 KB есть. libmpv (`dev.jdtech.mpv:libmpv`) меньше (≈25 МБ против 55 МБ несжатых `.so` на arm64), но артефакт неофициальный, а HDR в стоковом mpv-android уходит в SDR через GLES. Для VLC при `useLegacyPackaging=true` размер ≈ +24 МБ. + +## 7.4 Тестовый ТВ: Xiaomi TV S Pro Mini LED 75 (снято через adb 2026-10-12) + +| Параметр | Значение | +|---|---| +| Модель / ОС | `MiTV-MOEU1` (`lalaland`), Android 14 / API 34, `leanback_only` | +| SoC | MediaTek **MT9689** (`ro.hardware=mt5896`, платформа `merak`) | +| ABI | **только `armeabi-v7a`** (32-bit userspace) | +| RAM | 2,4 ГБ видно системе; `MemAvailable` ≈ 870 МБ (MemFree ≈ 60–240 МБ вводит в заблуждение — остальное кэш), swap 600 МБ почти занят — бюджет памяти умеренно жёсткий; хранилище 22 ГБ свободно | +| UI / дисплей | приложения рисуются в 1920×1080 (320 dpi), видеоплоскость 4K; Android видит **один режим 3840×2160@60** — переключение частоты через Android API недоступно; есть Xiaomi-настройка `mitv_vrr_frame_rate_enable_23` (не исследовано) | +| HDR дисплея | DV, HDR10, HLG, HDR10+ | +| HW-видео (`media_codecs-mt5896.xml`) | AVC/HEVC/VP9/AV1 до 8K; MPEG-2, VC-1, WMV3, MPEG-4, H.263, RealVideo, AVS/AVS2 до 1080p; DV-декодеры `dvav.per/pen`, `dvhe.der/den/dtr/stn/dth/dtb/st`, `dvav.se`, `dvav1` — т.е. профили 1–10, **включая P7 (`dvhe.dtb`)** | +| Аудио | Dolby MS12; выходы заявляют AC3, E-AC3, E-AC3-JOC, TrueHD, DTS, DTS-HD; сейчас активен только `speaker` | + +Расхождения с веб-исследованием: исследование по открытым источникам предполагало отсутствие P7 и VC-1 в железе — дамп устройства это опровергает (наличие в XML ≠ гарантированная работа через MediaCodecList; проверить в прототипе). + +Известные проблемы этого семейства (проверить на устройстве): jellyfin-androidtv #5825 — на Xiaomi S Pro (кит. HyperOS) media3-клиенты не включают DV, а Just Player включает; stutter `c2.mtk.hevc.decoder` (media3 #3347); вечная буферизация при AC-3 passthrough после быстрых seek на MTK audio HAL (#3407); искажённый E-AC3 passthrough на Xiaomi (jellyfin #5366); MTK DV-декодеру нужен 4-байтный start code для NAL 62/63 (квирк Kodi). + +## 7.5 Что проверить первым прототипом + +1. DV P5, P8.1, P7 (MEL/FEL), P8.1+HDR10+ через media3 — включается ли DV-режим ТВ (кейс #5825). +2. HDR10 / HDR10+ / HLG; возврат дисплея из HDR после выхода. +3. 24p-контент: есть ли judder при фиксированных 60 Гц; что делает `Surface.setFrameRate`. +4. VC-1 и MPEG-2 через media3+MediaCodec vs libVLC; H.264 Hi10P через libVLC (хватит ли CPU). +5. ASS со шрифтами (ass-media vs libVLC), PGS, VobSub. +6. Звук: динамики ТВ и (если есть) eARC — passthrough E-AC3/JOC, TrueHD, DTS-HD; быстрые seek при passthrough. +7. M2TS / WMV / ISO через libVLC; runtime-переход Exo → VLC при сбое. +8. Потребление памяти обоих движков на 2,4 ГБ RAM. diff --git a/docs/research/device-xiaomi.md b/docs/research/device-xiaomi.md new file mode 100644 index 0000000..2a9d5aa --- /dev/null +++ b/docs/research/device-xiaomi.md @@ -0,0 +1,134 @@ +# Xiaomi TV S Pro Mini LED 75: что важно знать для Jellyfin-клиента (media3 + libVLC/libmpv) + +Дата исследования: 2026-10-12. Пометка **(не проверено)** означает, что в первоисточнике факт не подтверждён: это вывод по аналогии, данные агрегатора или сообщение одного пользователя. + +--- + +## 0. Главное: под одним названием продаются три разных устройства + +| Вариант | Где продаётся | ОС | SoC | RAM/ROM | Комментарий | +|---|---|---|---|---|---| +| **Xiaomi TV S Pro Mini LED 75 2026** (глобальная, RU: артикул вида L75MB-S**RU**/-SME) | Глобальный и российский рынок, с конца 2025 / начала 2026 | **Google TV на Android TV 14** | Quad Cortex-A73 + **Mali-G52 (2EE) MC1**, по классу это MediaTek Pentonic 700 (вероятно MT9653) **(модель чипа не проверена)** | 3 / 32 ГБ | Наиболее вероятный тестовый девайс, если ТВ куплен в РФ/ЕС с Google TV | +| **Xiaomi TV S Mini LED 75 2025** (без «Pro», глобальная) | ЕС/РФ с конца 2024 | Google TV | Тот же Quad A73 + Mali-G52 MC1 | 3 / 32 ГБ | Платформа та же. На 4PDA модель 2026 называют «урезанной китайской S 2025» | +| **Xiaomi TV S Pro 75 2025/2026, китайская версия** | Китай (бывает в серых поставках) | **HyperOS 2/3** (Xiaomi TV OS, без GMS) | **MediaTek MT9655 (Pentonic 800)** + сопроцессор Xiaomi XM9000 | 4 / 64 ГБ | 240–330 Гц, до 5200 нит. Отдельный issue Jellyfin про DV (см. §3) | + +**Что сделать сразу:** определить вариант по `adb shell getprop ro.product.model`, `ro.board.platform`, `ro.hardware`, `ro.build.version.release` и `ro.product.device`. Список кодеков взять из `adb shell dumpsys media.player` или `/vendor/etc/media_codecs*.xml`, плюс посмотреть `dumpsys audio` и `dumpsys display`. Дальше в отчёте основной вариант — глобальная модель на Google TV, особенности китайской указаны отдельно. + +--- + +## 1. Характеристики (глобальная S Pro Mini LED 75 2026) + +Источник: официальная страница mi.com/global, проверено загрузкой страницы. + +- **CPU/GPU:** Quad Cortex-A73, Mali-G52 (2EE) MC1. **RAM 3 ГБ, ROM 32 ГБ.** + - По таблице Wikipedia сочетание A73 @1.4 ГГц + Mali-G52 MC1 соответствует **Pentonic 700 (MT9618 / MT9653 / MT9689)**. Pentonic 800 (MT9655) использует уже Mali-G57. + - Clubic называет для S Mini LED 2025 чип **MT9653** **(не проверено официально)**. + - Похожее сочетание есть у MT9613 (A73 1.3 ГГц, G52 MC1), но у него максимум 120 Гц, а у Pentonic 700 VRR до 144 Гц. Поэтому MT9653 вероятнее. +- **ОС:** Google TV, Android TV 14 (обзоры Onliner и FormulaTV на 55" 2026, модель L55MB-SRU). Это Android 14, API 34. +- **Панель:** QD-Mini LED VA, 4K, 704 зоны (75"), пиковая яркость 1700 нит. Xiaomi заявляет 144 Гц, Onliner пишет о **нативных 120 Гц** (144 Гц доступны в игровом режиме / HDMI enhanced). Game Boost даёт 288 Гц ценой падения разрешения до 1440p и глубины цвета до 8 бит. MEMC 4K120. VRR (FreeSync Premium), ALLM. +- **HDR:** Dolby Vision, HDR10+, HDR10, HLG, Filmmaker Mode. + - Dolby Vision IQ и IMAX Enhanced официально указаны только у S Mini LED 2025. У 2026 DV IQ, по Onliner, нет. + - Отзывы: в DV тёмные сцены теряют детали и выглядят темнее, чем в HDR10+ (Onliner, FormulaTV). Choose.tv о HDR тоже отзывается негативно. Can Buy or Not (индийская S Mini LED 2026) пишет, что на их образце DV не было вовсе. Индийская линейка, вероятно, на другой платформе **(не проверено)**. +- **Звук:** 2×15 Вт, Dolby Atmos, Harman AudioEFX (приходит через OTA). + - На глобальной странице mi.com **DTS не указан**. В российских обзорах (Onliner, FormulaTV) и в обзоре AbsoluteGeeks упомянут **DTS:X**. Сам чип MediaTek поддерживает «DTS Virtual:X». + - Вывод: декодер DTS в системе, скорее всего, есть, но это нужно проверить через `AudioManager`/`dumpsys audio` **(не проверено)**. +- **HDMI:** 3× HDMI 2.1 (CEC, ALLM, VRR). HDMI 1 и 2 поддерживают 4K144. **eARC на HDMI 2.** Третий порт без FRL (4K60, 8 бит). Есть оптический выход, 3.5 мм, USB 2.0 + USB 3.0, LAN, Wi-Fi 6, BT 5.2. +- **Netflix:** по Onliner, приложение не сертифицировано («эта версия несовместима с устройством»), FormulaTV пишет обратное. Это косвенный признак «сырой» сертификации прошивки. +- **Китайская версия (HyperOS):** MT9655, 4/64 ГБ, HyperOS 3.0, заявлен DTS:X. Источник — блог-обзоры, **(частично не проверено)**. + +--- + +## 2. Аппаратные видеодекодеры + +По данным MediaTek для MT9613 и таблицы Wikipedia для семейства MT96xx и Pentonic. + +| Кодек | Поддержка | Комментарий | +|---|---|---| +| HEVC Main10 4K60 | Да | Основной путь. Имена декодеров на MTK TV: `OMX.MTK.VIDEO.DECODER.HEVC` или `c2.mtk.hevc.decoder` | +| AV1 4K60 10-bit | Да | Заявлен у всего семейства MT96xx и Pentonic | +| VP9 Profile 2 (10-bit) | Да | 4K60@10bit по спецификации. Поддержку HDR-профиля проверить в MediaCodecList | +| H.264 | Да | High 4K. Hi10P (10-bit H.264) аппаратно почти наверняка **нет** — нужен софт-фоллбэк **(не проверено)** | +| AVS2/AVS3 | Да | Для нас неважно | +| MPEG-2 | Почти наверняка да | ТВ с DVB-T2/C/S2 обязан декодировать MPEG-2. Выставлен ли он в MediaCodec, **(не проверено)** | +| VC-1 / WMV3 | Неизвестно | На MTK TV чипах исторически есть, но наружу в MediaCodec часто не выставляется **(не проверено)**. Предусмотреть софт (ffmpeg в libVLC/libmpv) | +| Dolby Vision | Да (декодер `video/dolby-vision`) | Имена у MTK: `OMX.MTK.VIDEO.DECODER.DVHE.STN` (видели на Fire TV на MTK) или `…DVHE.STH`. Профили 5 и 8.x — да. **Профиль 7 (dual-layer, FEL/MEL) аппаратно — скорее всего нет** (общая ситуация для Android TV, PR Jellyfin #5470) **(для этого SoC не проверено)** | + +**Квирк MTK DV-декодера (Kodi).** Декодер `OMX.MTK…DVHE` ожидает 4-байтовые start code (`00 00 00 01`) для NAL-юнитов типов 62/63 (RPU и EL). Kodi включает «DoviWorkaround» в BitstreamConverter для `OMX.MTK*` + `video/dolby-vision` и для `OMX.MTK` задаёт invalid-PTS = −1 («use DTS»). Источник: патч quietvoid, вошёл в Kodi 20 через PR #19099. Для собственного пайплайна (не media3) это обязательно. + +--- + +## 3. Известные проблемы на этом ТВ и SoC-семействе + +### 3.1. Прямо про Xiaomi S Pro + +- **jellyfin-androidtv #5825 (2026-09, открыт).** Xiaomi S Pro 2025 65", китайская, **HyperOS 3.0**. После обновления до HyperOS 3.0 Dolby Vision во встроенном плеере Jellyfin (ExoPlayer/media3) **не включается**: «неправильные цвета», т.е. сигнал идёт как HEVC/HDR10 или вообще без HDR. То же в Wholphin. А **Just Player** (тоже media3) и системный плеер Xiaomi включают DV на том же файле (WEB-DL DV P8 без HDR10+). Подтверждено вторым пользователем на beta2. + - Вывод: на этой платформе ExoPlayer-путь Jellyfin неверно выбирает декодер или неверно определяет DV, а у Just Player это работает. Стоит изучить, как Just Player выбирает декодер `video/dolby-vision` (MediaCodecSelector / DV→HEVC fallback) и как сигнализирует HDR. +- **jellyfin-androidtv #5366.** «Xiaomi TV / Mi Box», Android 9: E-AC3 в passthrough звучит «как из коробки», Kodi же декодирует в stereo нормально. Закрыт без решения. Модель не указана, поэтому к нашему ТВ относится только косвенно. +- **4PDA (тема S Mini LED 2025):** со временем перестаёт работать MEMC и появляются подёргивания. **Проблемы со звуком по SPDIF при включённом MEMC** (вероятно, рассинхрон или задержка) **(не проверено, пересказ)**. Тема S Pro 2026: https://4pda.to/forum/index.php?showtopic=1115060 (сам 4PDA вернул 403, содержание взято из поисковых сниппетов). +- Onliner (55" 2025, глобальная): жалобы на зависания и проблемы с приложениями. + +### 3.2. Семейство MediaTek Pentonic 700 / MT96xx (TCL, Hisense, Philips, Sony) + +- **Kodi на Hisense U7K (MT9618, Pentonic 700):** в dev-сборке Kodi 21 DV-файлы давали чёрный экран при рабочем звуке, после ноябрьского nightly MP4-DV заиграл. На U8H предыдущего поколения DV через Kodi работал (forum.kodi.tv tid=374191). Там же сказано, что Jellyfin и Plex DV на U7K играют. +- **Hisense:** Kodi видит DV-дисплей, но при DV-воспроизведении ТВ показывает HDR10+. Значит, сигнализация HDR через MediaCodec не всегда соответствует ожиданиям. +- **DV P7:** по общему консенсусу Kodi-форума, Android-устройства P7 «как есть» не поддерживают. Варианты: режим совместимости Kodi (P7 → P8.1, EL отбрасывается, по качеству это примерно MEL) или фоллбэк на HDR10 base layer. FEL на встроенных ТВ-приложениях фактически недостижим. +- **Philips Android TV на MediaTek (jellyfin #5827, media3 #3421):** `OMX.MTK.VIDEO.DECODER.HEVC` **падает посреди потока** (vendor-internal error после успешной инициализации, при resume/seek в конкретный файл). `setEnableDecoderFallback` это не ловит, потому что работает только при ошибке init. Обходной путь: свой `MediaCodecSelector`, который исключает упавший декодер, затем re-prepare + seek, с переходом на `c2.android.hevc.decoder`. Jellyfin PR отклонил, issue в media3 открыт. +- **media3 #2711 / #2765:** MediaTek HEVC перестаёт выдавать кадры или выдаёт чёрный/замёрзший кадр после OS-апдейтов (это телефоны Dimensity, но драйверы того же вендора). +- **media3 #3347:** `c2.mtk.hevc.decoder` на Google TV Streamer (MT8696) отдаёт около 30% кадров с опозданием 30–130 мс на некоторых x265-файлах, отсюда постоянный stutter. **VLC на том же железе работает нормально.** Аргумент в пользу libVLC/libmpv как фоллбэка. +- **media3 #3407:** Sony Bravia (Android 12, **MediaTek audio HAL**), AC-3 passthrough без tunneling. После двух быстрых seek плеер навсегда остаётся в BUFFERING: позиция AudioTrack идёт назад и трактуется как 32-битный wrap. В tunneled-режиме (HW_AV_SYNC) не воспроизводится. +- **jellyfin #5028:** Philips (MediaTek) — E-AC3 7.1 при транскоде даёт slow-motion без звука, downmix в stereo помогает. +- **jellyfin #5093:** TCL C855 (MediaTek, Android 12) — регрессии в capability-репорте 0.19.x (DOVIInvalid, P8 с CCID 6, «range type not supported») вызывают лишний транскод. Урок: не отбрасывать «невалидные» DV-потоки, а давать фоллбэк на HDR10. +- **jellyfin #4021:** файлы DV + HDR10+ на многих Android TV дают чёрный экран. Нужна настройка «игнорировать HDR10+ в DV-файлах», как «Allowed HDR dynamic metadata formats» в Kodi. Для нашего ТВ (поддерживает и DV, и HDR10+) это менее критично, но правило полезно для универсальности. + +### 3.3. Аудио passthrough на ТВ (встроенные приложения → eARC) + +- По опыту TCL 2022/2023 (MediaTek) из XDA: из **внутренних приложений** ТВ отдаёт по eARC DD, DD+, DD+ Atmos (JOC) и AC-4, а **TrueHD — «no support»** (по сообщениям, конвертируется в DD+ без Atmos). DTS-HD MA и DTS:X на TCL 2022 тоже отмечены как «no support». Форумная формулировка: «нет ТВ на рынке, который выдаёт TrueHD из внутренних приложений». +- Для Xiaomi это **не проверено**, но ожидаемо то же самое: **TrueHD и DTS-HD MA bitstream из приложения, скорее всего, не пройдут**. Правильное поведение клиента — опрашивать `AudioManager` (`getDirectProfilesForAttributes` / `isDirectPlaybackSupported` на API 33+, `AudioCapabilities` в media3) и при отсутствии поддержки декодировать в PCM на клиенте (ffmpeg/libVLC: TrueHD → PCM 7.1 или EAC3-реэнкод), а не транскодировать на сервере. +- **DTS:** у глобальной модели в спецификации нет, в российских обзорах указан DTS:X **(не проверено)**. Опираться только на рантайм-проверку `ENCODING_DTS`/`ENCODING_DTS_HD` и иметь софтверный DTS-декодер (ffmpeg dca) как фоллбэк. Тренд на отказ от DTS подтверждается: LG убрал DTS из ТВ 2025 года, Google TV Streamer не умеет DTS-HD/TrueHD passthrough. +- Сценарий «Jellyfin/Kodi на внешнем боксе → HDMI → ТВ → eARC → AVR» пропускает TrueHD и DTS-HD. Для приложения на самом ТВ это не применимо. + +--- + +## 4. Общие особенности MediaTek Android TV для media3 и libVLC/libmpv + +1. **Dolby Vision через MediaCodec.** + - Опрашивать `MediaCodecList` по `video/dolby-vision` с профилями: DvheStn = P5, DvheSt = P8. P7 на MTK обычно не объявлен. + - При отсутствии P7: преобразовывать P7 → P8.1 на лету (выкинуть EL, переписать RPU; так делают Kodi, `DvCompatVideoRenderer` из PR #5470 и dovi_tool) или играть BL как HDR10. + - Учесть, что media3 `MatroskaExtractor` может не распознать DV при «раздутом» CodecPrivate от MakeMKV (~800 байт). Фоллбэк — `videoRangeType` от сервера Jellyfin. + - На этом ТВ (особенно HyperOS 3.0) проверить, какой декодер выбирает media3 и запускается ли DV-режим: см. #5825, Just Player справляется. + - Для libVLC/libmpv: DV через mediacodec в libmpv (`hwdec=mediacodec`) зависит от передачи `video/dolby-vision`. libmpv обычно отдаёт HEVC, и получается HDR10 base layer. libVLC 4.x умеет DV через MediaCodec ограниченно **(не проверено)**. Реалистично DV только через media3, а libVLC/libmpv использовать для HDR10/HLG/SDR и экзотических кодеков. +2. **HDR-сигнализация.** На ТВ (в отличие от бокса) режим HDR включает видеопайплайн по метаданным декодера, переключения HDMI-режима нет. Нужен корректный `KEY_COLOR_TRANSFER`/`KEY_HDR_STATIC_INFO`, иначе картинка «блёклая». Для libmpv лучше `vo=mediacodec_embed` (прямой вывод в Surface с HDR-метаданными), а не gpu/gpu-next: GPU-рендер на Mali-G52 MC1 4K HDR не потянет, и HDR-режим ТВ не включится. +3. **Tunneled playback.** MTK TV декодеры обычно объявляют `FEATURE_TunneledPlayback` **(для этого SoC не проверено)**. Плюсы: точный A/V sync с HW_AV_SYNC, меньше рывков, в #3405 туннелирование помогло пропускной способности, баг #3407 в туннельном режиме не воспроизводится. Минусы: не все аудиоформаты (passthrough + tunneling на некоторых HAL ломается), не работают субтитры поверх с некоторыми рендерерами, ограничения seek. Нужен пользовательский переключатель и тест на устройстве. +4. **Смена частоты (AFR).** + - Встроенная панель 120/144 Гц: «переключение режима HDMI» как на боксе не нужно, но ТВ должен перейти на кратную частоту (24p → 120 Гц, 5:5) без judder. + - На Android 12+ есть системная опция «Match content frame rate» (Display & Sound → Advanced display) и API `Surface.setFrameRate` (media3: `setVideoChangeFrameRateStrategy`). Seamless-переход поддерживается, non-seamless требует `preferredDisplayModeId`. + - Как Xiaomi реализует это на 144-герцовой панели и не сбивает ли MEMC, **(не проверено)**. Протестировать 23.976/25/50/59.94 и проверить `dumpsys display` (supportedModes) и `dumpsys SurfaceFlinger`. +5. **Слабое железо.** 3 ГБ RAM и Mali-G52 MC1: софт-декод 4K HEVC/AV1 невозможен. libVLC/libmpv использовать только с hwdec (mediacodec). ASS-субтитры (libass) на 4K-Surface — нагрузка на CPU, а overlay-рендер поверх HDR-видео может ломать HDR (#5576: PGS + DV/HDR вызывает ошибку). Высокие битрейты (UHD-ремуксы 80–100 Мбит/с) требуют большого буфера и быстрой сети. + - **Проверить скорость Ethernet-порта:** у многих ТВ он 100 Мбит/с **(для этой модели не проверено)**. Для ремуксов UHD BD это критично, Wi-Fi 6 может оказаться быстрее. +6. **Ошибки декодера посреди потока** (MTK HEVC, #5827/#3421): реализовать runtime-fallback (исключить декодер → re-prepare → seek) и/или переход на libVLC/libmpv. +7. **AudioTrack passthrough на MTK HAL** (#3407): после быстрых seek возможна «вечная буферизация». Нужен watchdog: если плеер буферизуется дольше N секунд при полном буфере, делать re-seek или flush. +8. **E-AC3 passthrough** на некоторых Xiaomi/MTK звучит искажённо (#5366). Нужна настройка «декодировать EAC3 в PCM» на клиенте. + +--- + +## 5. Чек-лист проверок на реальном ТВ + +1. `adb shell getprop | grep -iE "model|platform|hardware|board|version.release|build.fingerprint"`: какая из трёх моделей и какой SoC. +2. `adb shell dumpsys media.player` и `cat /vendor/etc/media_codecs*.xml`: список декодеров, профили DV (dvhe.05/.07/.08?), VC-1, MPEG-2, H.264 Hi10, tunneling. +3. `adb shell dumpsys audio | grep -iA5 "encodings\|formats"` + `dumpsys media.audio_flinger`: какие форматы HAL поддерживает в direct/offload при подключённом eARC-ресивере. Проверить AC3/EAC3/EAC3-JOC/TrueHD/DTS/DTS-HD. +4. `adb shell dumpsys display | grep -iE "mode|hdr"`: supportedModes (60/120/144?), HdrCapabilities (DV, HDR10, HDR10+, HLG). +5. Тест-файлы: DV P5 (MP4), P8.1 (MKV WEB-DL), P7 MEL/FEL (MakeMKV), DV + HDR10+; HDR10+ AV1; VP9.2; VC-1 и MPEG-2 (BD/DVD ремуксы); H.264 Hi10P; TrueHD Atmos, DTS-HD MA, DTS:X, EAC3-JOC. +6. Сравнить media3 и Just Player на одном DV-файле: воспроизвести #5825. + +--- + +## Источники + +- Официальные спецификации: https://www.mi.com/global/product/xiaomi-tv-s-pro-mini-led-75-2026/specs/ , https://www.mi.com/global/product/xiaomi-tv-s-mini-led-75-2025/specs/ , FAQ https://www.mi.com/ph/support/faq/details/KA-483202/ +- MediaTek MT9613: https://www.mediatek.com/products/pentonic/mt9613 ; таблица SoC: https://en.wikipedia.org/wiki/List_of_MediaTek_systems_on_chips +- Обзоры: https://tech.onliner.by/2026/02/12/xiaomi-s-pro-mini-led-2026 , https://mo.formulatv.ru/obzory/televizory/obzor-televizora-xiaomi-tv-s-2026.html , https://www.absolutegeeks.com/reviews/xiaomi-tv-s-pro-mini-led-75-2026-review-mini-led-mastery-unleashed/ , https://xiaomiforall.com/xiaomi-tv-s-pro-mini-led-2026-review/ (китайская: MT9655, 4/64, HyperOS 3), https://www.clubic.com/544037-xiaomi-s-mini-led-2025.html (MT9653), https://www.notebookcheck.net/2026-Xiaomi-TV-S-Pro-Mini-LED-series-goes-global-but-specs-fall-short-of-Chinese-models.1122730.0.html +- 4PDA: https://4pda.to/forum/index.php?showtopic=1115060 , https://4pda.to/forum/index.php?showtopic=1102774 +- Jellyfin: https://github.com/jellyfin/jellyfin-androidtv/issues/5825 , /issues/5366 , /pull/5827 , /issues/5093 , /pull/5470 , /issues/5028 , /issues/4021 +- media3: https://github.com/androidx/media/issues/3421 , /issues/3347 , /issues/3407 , /issues/3405 , /issues/2711 +- Kodi: https://forum.kodi.tv/showthread.php?tid=374191 (U7K MT9618), https://gist.github.com/quietvoid/db7ac49bd8621a82f914888bca730c74 (MTK DV NAL quirk), https://github.com/xbmc/xbmc/pull/22950 (OMX.MTK.VIDEO.DECODER.DVHE.STN) +- Аудио: https://xdaforums.com/t/tcl-2023-google-tvs-p745-p755-c645-c745-c755-c805-c845-c855-c955-x955.4576005/page-36 , https://xdaforums.com/t/tcl-2022-google-tvs-p635-p735-c635-c735-c835-c935.4432635/page-41 , https://www.techradar.com/televisions/lg-removes-dts-audio-from-its-2025-tvs-heres-what-that-means-in-practice , https://www.aftvnews.com/google-tv-streamer-does-not-support-dolby-truehd-dts-hd-or-dtsx-audio-passthrough/ diff --git a/docs/research/engines.md b/docs/research/engines.md new file mode 100644 index 0000000..41e8271 --- /dev/null +++ b/docs/research/engines.md @@ -0,0 +1,511 @@ +# Несколько движков воспроизведения в Android-клиенте Jellyfin: архитектура, выбор движка, fallback + +Дата: 2026-10-12. Метод: shallow-клоны репозиториев (HEAD на 10–12.10.2026) и чтение исходников, Maven Central (maven-metadata, POM, скачанные AAR: размеры `.so`, выравнивание ELF LOAD через `readelf`), GitHub API, документация Android/Kodi. +Всё, что помечено «(не проверено)», не подтверждено кодом или первоисточником. + +--- + +## 0. TL;DR + +1. **Почти все клиенты выбирают движок вручную, глобальной настройкой** (Findroid, AFinity, JellyPlay, Swiftfin, Streamyfin, Nova, Just Player). Автоматика есть только у трёх: + - **Wholphin**: режим `PREFER_MPV` — если контент HDR (или 4K при software-декоде), берётся ExoPlayer, иначе mpv. + - **Void**: режим `AUTO` — HDR или Dolby Vision идут в ExoPlayer, всё остальное в mpv. + - **Kodi**: правила в `playercorefactory.xml` по протоколу, расширению, кодеку, разрешению и `hdrtype`; плюс меню «Play using…». +2. **Автоматическое переключение на другой движок при runtime-ошибке декодера реализовано только в Plezy** (Flutter-клиент Plex, нативная часть на Kotlin). Схема ExoPlayer → mpv: + - сначала попытка восстановиться внутри ExoPlayer; + - срабатывает по `onPlayerError`, когда трек видео не выбран, по watchdog «декодер завис» (входные буферы есть, выходных нет) и по watchdog «чёрный экран»; + - воспроизведение продолжается с той же позиции. + + Wholphin и AFinity при ошибке переходят на **серверный транскод тем же движком**. JellyPlay сознательно не делает «тихого» fallback. +3. **DeviceProfile везде свой для каждого движка, объединения возможностей нет ни у кого.** + - Wholphin: `if (backend == EXO_PLAYER) deviceProfileService… else mpvDeviceProfile`. + - Swiftfin: `VideoPlayerType.directPlayProfiles/transcodingProfiles/subtitleProfiles`. + - JellyPlay: `DeviceProfileProvider.forPlayer(PlayerType)`. + - Findroid: шлёт пустой профиль «Direct play all» и всегда берёт статический поток. +4. **Абстракция бывает двух видов.** + - Движок как media3 `Player`. Findroid и AFinity: `MPVPlayer : BasePlayer`. Wholphin: `MpvPlayer : BasePlayer`, стаб `: SimpleBasePlayer`. + - Собственный интерфейс: jellyfin-androidtv `PlayerBackend`, JellyPlay `MediaEngine` (~640 строк контракта + `EngineCapabilities`), Swiftfin `MediaPlayerProxy` + capability-протоколы, Nova `IMediaPlayer`. + + Реализации «mpv как Player» полны `TODO()` и `UnsupportedOperationException`. В Findroid около 15 мест `TODO("Not yet implemented")`, включая `setVideoSurface` и `setVideoTextureView`. +5. **Рекомендация по архитектуре.** Свой узкий интерфейс `PlaybackEngine`, разбитый на capability-интерфейсы (как у Swiftfin), и **один** адаптер `EngineSessionPlayer : SimpleBasePlayer` в ядре. Адаптер нужен для MediaSession, состояний media3-ui-compose и `PlayerSurface`; подход как у jellyfin-androidtv `MediaSessionPlayer : SimpleBasePlayer`. Каждый движок живёт в отдельном Gradle-модуле и регистрируется через Hilt `@IntoSet`. Наборы движков задаются product flavors `full`/`lite`. Стаб-модули не нужны. +6. **Fallback-движок сейчас: libVLC 3.7.x (`org.videolan.android:libvlc-all:3.7.7`).** + - официальный артефакт VideoLAN с регулярными релизами (3.7.6 — 11.09.2026, 3.7.7 — 01.10.2026); + - стабильный Java API; + - HDR через MediaCodec на Surface (не проверено в этом раунде); + - внутри libass, PGS, DVB и VobSub; + - выравнивание 16 КБ на месте. + + Минус — размер: arm64 `.so` занимают 55 МБ без сжатия и 24 МБ со сжатием. libmpv стоит держать вторым движком на будущее, но не делать основным fallback: у него нет официального Android-артефакта, и в стоковом виде он не выводит HDR (тонмапит в SDR через GLES). + +--- + +## 1. Как устроено в существующих клиентах + +### 1.1 Findroid (jarnedemeulemeester/findroid, Kotlin) +- **Модули:** `:player:core`, `:player:local`, `:app:phone`, `:app:tv`. Движки лежат в `player/local/src/main/java/dev/jdtech/jellyfin/player/local/`: + - `mpv/MPVPlayer.kt` (1659 строк) — `class MPVPlayer(...) : BasePlayer(), MPVLib.EventObserver, AudioManager.OnAudioFocusChangeListener`. + - ExoPlayer используется напрямую. +- **Выбор:** `PlayerViewModel.kt` читает `appPreferences.playerBackend` и делает `when ("exoplayer" | "mpv")` (стр. 135–173). Это глобальная пользовательская настройка, по умолчанию `"exoplayer"` (`AppPreferences.kt:29`). Автоматики нет. +- **Ошибки:** fallback отсутствует, `onPlayerError` в ViewModel не используется для смены движка. +- **DeviceProfile:** один для всех движков — `DeviceProfile(name="Direct play all", directPlayProfiles=empty, transcodingProfiles=empty, …, subtitleProfiles=[srt,ass EXTERNAL])` (`data/.../JellyfinRepositoryImpl.kt:~315`). Дальше всегда `getVideoStreamUrl(static=true)`, то есть клиент сознательно не пользуется серверным транскодом. +- **Зависимость:** `dev.jdtech.mpv:libmpv:1.0.0` подключена как `implementation` в `:player:local` и как `compileOnly` в `:app:phone`. +- **Сборка:** один flavor `libre`, ABI splits для `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`. Для сборки bundle splits отключаются из-за issue AGP 402800800. +- **Состояние MPVPlayer:** около 15 `TODO("Not yet implemented")`, включая `setVideoSurface`, `setVideoSurfaceHolder` и `setVideoTextureView`. Реализован только `setVideoSurfaceView`. + +### 1.2 Wholphin (damontecres/Wholphin, Android TV, Compose) +- **Модули:** `:app` и `:wholphin-mpv-stub`. Настоящие нативные модули живут в отдельном репозитории `damontecres/wholphin-extensions`: `wholphin-mpv` и `wholphin-media3` (ffmpeg- и av1-декодеры). Они публикуются в **GitHub Packages, а там анонимное чтение невозможно** — README требует classic PAT. +- **Подключение в `app/build.gradle.kts` (стр. 30–36, 370–397), каскадом:** + 1. локальный `libs/wholphin-mpv-release.aar`; + 2. иначе prebuilt из репозитория `WholphinExtensions…`; + 3. иначе `project(":wholphin-mpv-stub")`. + + То же сделано для ffmpeg и av1. +- **Стаб:** `wholphin-mpv-stub/.../MpvPlayer.kt` — `class MpvPlayer(...) : SimpleBasePlayer(Looper.getMainLooper()) { override fun getState() = throw MpvStubException() }`. Он нужен потому, что код `:app` напрямую ссылается на класс `MpvPlayer`. +- **Абстракция:** media3 `Player`. `services/PlayerFactory.kt` возвращает `PlayerCreation(player: Player, assHandler)`. Настоящий `MpvPlayer` в extensions — `BasePlayer()`, около 1600 строк; часть методов бросает `UnsupportedOperationException`. +- **MediaSession:** `MediaSessionPlayer : ForwardingSimpleBasePlayer(player)`. +- **Видео:** `PlayerSurface(player, surfaceType = SURFACE_TYPE_SURFACE_VIEW)` из media3-ui-compose, один и тот же для Exo и mpv. +- **Выбор** (`ui/playback/PlaybackViewModel.kt:234–270`): + ```kotlin + PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) EXO_PLAYER else MPV + ``` + Варианты `PlayerBackend`: `EXO_PLAYER`, `MPV`, `PREFER_MPV`, `EXTERNAL_PLAYER`. Движок можно передать и через `Destination.Playback.backend`, то есть выбрать «Play with…» для конкретного элемента. Если движок изменился, старый `Player` уничтожается и создаётся новый. +- **Ошибки** (`onPlayerError`, стр. ~1377): при `DIRECT_PLAY`/`DIRECT_STREAM` вызывается `changeStreams(..., positionMs = player.currentPosition, enableDirectPlay=false, enableDirectStream=false)`, то есть **переход на транскод тем же движком** с той же позиции. Смены движка нет. +- **DeviceProfile на каждый движок** (стр. ~660): + ```kotlin + deviceProfile = if (backend == EXO_PLAYER) deviceProfileService.getOrCreateDeviceProfile(...) else mpvDeviceProfile + ``` + `util/mpv/MpvDeviceProfile.kt` — статический профиль с широким набором контейнеров и кодеков, у PGS/DVDSUB/DVBSUB стоят флаги `embedded=true, encode=true`. +- **Упаковка:** `packaging.jniLibs.pickFirsts += "lib/*/libc++_shared.so"`, потому что libass-android и wholphin-mpv оба кладут `libc++_shared`. Flavors `default`, `appstore`, `firetv` отличаются фичами, не движками. + +### 1.3 JellyPlay (raulshma/jellyplay, KMP, бета, создан в мае 2026) +- **Документ:** `docs/player-engines.md`. Три движка: ExoPlayer (по умолчанию), libmpv (`io.github.abdallahmehiz:mpv-android-lib:0.1.12`), LibVLC (`libvlc-all:3.7.5`). Есть ещё External. +- **Абстракция:** собственный `MediaEngine` (`shared/core/player-contract/src/commonMain/.../engine/MediaEngine.kt`, 640 строк): + - `load(PlaybackRequest)`, `play/pause/seekTo`, `StateFlow` для состояния, позиции, треков и cues; + - `capabilities: EngineCapabilities` — около 25 флагов: `supportsAudioDelay`, `supportsSubtitleDelay`, `supportsAudioPassthrough`, `supportsAssOverride`, `supportsVideoFilters` и т. д.; + - `updateConfig(EngineConfig)`, `setAspectRatio`, `zoomSafeSubtitleStrategy`. + + UI скрывает элементы управления по флагам capability. Поверхность вынесена в платформенный seam `AndroidSurfaceProvider`. +- **MediaSession:** только у Exo, через `Media3PlayerHost`/`asMedia3Player()`. Для mpv и VLC «JellyPlay provides its own». +- **Выбор:** в документе сказано прямо — «The choice is **per-device and manual** — JellyPlay does not auto-pick an engine based on the file's codec». Раздел «**No silent fallback.** If the chosen engine fails to decode a file, JellyPlay reports the error…» +- **DeviceProfile на каждый движок:** `shared/core/network/.../DeviceProfileProvider.kt` → `forPlayer(PlayerType)`: + - MPV получает свой профиль (два варианта, с direct play для PGS и без); + - EXO_PLAYER и LIBVLC получают общий `hardwareProfile`. + + Passthrough-кодеки убираются из direct-play только для движков, которые умеют bitstream. +- **Сборка:** flavors `phone`/`tv` (по платформе, не по движкам). ABI split по умолчанию только arm64 (`extraAbis` через property). +- **Неточности в документе:** + - «ExoPlayer — no audio passthrough» неверно: media3 делает passthrough AC3, E-AC3, DTS и TrueHD через `AudioCapabilities`, а Plezy поверх этого реализует IEC-упаковку. + - «LibVLC does not render ASS» неверно для libVLC как такового: в `libvlc.so` из 3.7.7 есть `ass_render_frame` и исходники `libass/*.c`, это проверено через `strings`. + + Скорее всего, оба утверждения описывают их собственную интеграцию, а не возможности библиотек. + +### 1.4 AFinity (MakD/AFinity) +- Использует `dev.jdtech.mpv:libmpv:1.0.0`. `app/.../player/mpv/MPVPlayer.kt` — производная от Findroid, media3 `Player`. +- **Выбор:** булева настройка `preferencesRepository.useExoPlayer` (`PlayerViewModel.initializePlayer`). Автоматики нет. +- **Ошибки:** `retryWithTranscodeFallback(error)` — переход на транскод, движок не меняется. +- **MediaSession:** строится поверх любого `Player` (`MediaSession.Builder(context, player)`). Это главный плюс подхода «движок = Player». + +### 1.5 Void for Jellyfin (hritwikjohri/Void-for-jellyfin; VoidTV отдельно) +- **mpv:** `dev.marcelsoftware.mpvcompose:mpv-compose:1.0.0` — Compose-обёртка, исходники в nitanmarcel/mpv-compose, единственная версия от 29.06.2025. В `MPVPlayerView.kt` работа идёт прямо через `MPVLib`. +- **Выбор:** `PlayerType { AUTO, MPV, EXOPLAYER }`. В `VideoPlayerScreen.kt:~120–170` для `AUTO` действует правило `selectedVideoStream.isHdrOrDovi() → EXOPLAYER, иначе MPV`; если выбранный движок не может получить URL, берётся второй. Это решение в момент запуска, а не реакция на ошибку. +- **Активность:** последний push Void-for-jellyfin — 31.03.2026, VoidTV — 30.01.2026. Проект стоит. + +### 1.6 Streamyfin (React Native/Expo) +- **Модули:** собственные Expo-модули `modules/mpv-player` (Android: `dev.jdtech.mpv:libmpv:1.0.0`, iOS: свой) и `modules/exoplayer-player`. +- **Выбор:** `utils/atoms/settings.ts`. **MPV — «universal default», ExoPlayer — opt-in только на Android TV**: `getActiveVideoPlayerEngine()`, `isExoPlayerSupported`. VLC из проекта убрали (раньше был основным, не проверено). + +### 1.7 Swiftfin (jellyfin/Swiftfin, iOS/tvOS) +- **Перечисление движков:** `Shared/Objects/VideoPlayerType/VideoPlayerType.swift` — `enum VideoPlayerType { avPlayer = "native", vlc, mpv }`. mpv включается экспериментальным флагом `Defaults[.Experimental.mpvPlayer]`. +- **Профиль:** каждый тип **сам отдаёт** `directPlayProfiles`, `transcodingProfiles` и `subtitleProfiles` (vlc и mpv пока делят VLC-профили). DeviceProfile собирается из профилей выбранного движка: `MediaPlayerItem+Build.swift` → `for: videoPlayerType`. +- **Абстракция** (`MediaPlayerProxy.swift`) — набор протоколов, разделённых по возможностям. Это удачный шаблон: + - `MediaPlayerProxy` (play/pause/stop/jump/setRate/setSeconds); + - `VideoMediaPlayerProxy` (videoSize, droppedFrames); + - `MediaPlayerAudioTrackConfigurable`, `MediaPlayerSubtitleTrackConfigurable` (работают с Jellyfin `MediaStream`, а не с внутренними треками движка); + - `MediaPlayerOffsetConfigurable` (`setAudioOffset`/`setSubtitleOffset`); + - `MediaPlayerPictureInPictureCapable`, `MediaPlayerSubtitleConfigurable`, `VideoMediaPlayerLayoutConfigurable`. + + Реализации: `MediaPlayerProxy+VLC.swift`, `+AVPlayer.swift`, `+MPV.swift`. +- **Выбор:** глобальная настройка `Defaults[.VideoPlayer.videoPlayerType]`, по умолчанию `.vlc`. Автоматики и fallback нет. + +### 1.8 Plezy (edde746/plezy, Flutter, клиент Plex) — единственный с авто-fallback +- **Схема:** на Android основной движок ExoPlayer (`android/app/.../exoplayer/ExoPlayerCore.kt`), запасной — mpv (`.../mpv/MpvPlayerCore.kt`). Оба управляются плагином `ExoPlayerPlugin.kt`, который хранит `playerCore` и `mpvCore` и флаг `usingMpvFallback`. Настройка `android_use_exoplayer` позволяет сразу выбрать mpv. +- **Триггеры fallback**, все сводятся к `requestFormatFallback(...)` → `delegate.onFormatUnsupported(uri, headers, positionMs, playWhenReady, errorMessage)`: + 1. `onPlayerError`, если не помогла попытка восстановиться внутри Exo (`retryVideoDecoderInPlace`); + 2. `onTracksChanged`: группа видео есть, но ни одна не выбрана, то есть нет декодера (`requestFallbackForUnsupportedTracks`); + 3. **watchdog «decoder hang»**: `queuedInputBufferCount > 0`, а выходных буферов 0 после таймаута; + 4. **watchdog «чёрный экран»**: 0 отрисованных кадров за `WATCHDOG_TIMEOUT_MS` при `isPlaying`; + 5. отказ аудио-sink после исчерпания восстановления внутри Exo. +- **Политика повторов** (`VideoDecoderRecoveryPolicy`): если кадр уже был отрисован, формат заведомо поддерживается, поэтому сначала повтор в Exo (`MAX_CONSECUTIVE_ATTEMPTS=1`, `MAX_ATTEMPTS_PER_MEDIA=3`), и только потом mpv. Для Dolby Vision перед fallback делается попытка `retryWithDvConversion` (DV → HEVC/HDR10). +- **Передача между движками:** + - счётчик `mediaGeneration` отсекает устаревшие события; + - повторные запросы fallback схлопываются; + - позиция ограничивается у конца файла (`EndOfStreamPolicy.fallbackStartPositionMs`); + - внешние субтитры и mpv-свойства, выставленные до fallback, воспроизводятся заново; + - таймаут инициализации mpv — 10 с. +- **mpv:** собственная сборка `edde746/mpv-build` (`mpv-build.lock.json` с SHA-256, ffmpeg 8.0.1). При HW-декоде используется **форк `vo=mediacodec`**: буферы MediaCodec сразу идут в compositor, 10-бит и HDR10/HLG dataspace сохраняются, субтитры рисуются на соседней OSD-поверхности. Комментарий в `MpvPlayerCore.kt:165–189` фиксирует, что `gpu-next` + HW-декод ломается на Tegra (синий экран на Shield). Это хорошо показывает, что HDR в стоковом mpv-android не решён. + +### 1.9 jellyfin-androidtv (официальный) +- **Модули:** `playback/core` (`backend/PlayerBackend.kt`, `BackendService.kt`, `support/PlaySupportReport.kt`), `playback/media3/exoplayer` (`ExoPlayerBackend`), `playback/media3/session` (`MediaSessionPlayer : SimpleBasePlayer(looper)` поверх `PlaybackManager`), `playback/jellyfin`. +- **`PlayerBackend`:** + - `supportsStream(MediaStream): PlaySupportReport` — Exo реализует через `exoPlayer.getPlaySupportReport(stream.toFormats())` по `RendererCapabilities`; + - `setSurfaceView`, `setSubtitleView`, `prepareItem/playItem`, `seekTo`, `setSpeed`, `setTimedEvents`. + + `BackendService.switchBackend()` останавливает старый backend и переносит на новый surface, subtitle view и listener. Задел под несколько движков есть, но в коде только ExoPlayer; libVLC из клиента давно убрали, VLC остался внешним плеером (`VlcExternalPlayerApi.kt`). + +### 1.10 Just Player (moneytoo/Player) +Движок один — ExoPlayer с локальными AAR `lib-decoder-ffmpeg`, `av1`, `iamf`, `mpegh`. Настройка `decoderPriority` → `setExtensionRendererMode` (OFF/ON/PREFER). Это «выбор декодера внутри одного движка», а не выбор движка. Приём полезен и нам: `EXTENSION_RENDERER_MODE_ON` плюс `setEnableDecoderFallback(true)`. + +### 1.11 Nova Video Player (nova-video-player/aos-*) +- **Интерфейс:** `aos-MediaLib/src/com/archos/medialib/IMediaPlayer.java` с `TYPE_AVOS=0` и `TYPE_ANDROID=1`. Методы `setSubtitleDelay`, `setAvDelay`, `setSubtitleRatio`, `setAudioFilter` присутствуют прямо в интерфейсе. +- **Фабрика** `MediaFactory.createPlayer(ctx, forceSoftwareDecoding)`: + - настройка `KEY_DEC_CHOICE == -1` → `AndroidMediaPlayer`; + - иначе `AvosMediaPlayer` — свой нативный плеер на ffmpeg с `MP_DECODER_ANY` (HW, затем SW) или `MP_DECODER_SW`; + - если `LibAvos` недоступен, используется Android-плеер. +- В манифесте `aos-AVP/exoplayer.xml` есть экспериментальная ветка `dev/exoplayer`. + +### 1.12 Kodi +- **Выбор плеера:** `system/playercorefactory.xml` и пользовательский `userdata/playercorefactory.xml` со списком `` и ``. Атрибуты правил (`xbmc/cores/playercorefactory/PlayerSelectionRule.cpp`): + - `internetstream`, `remote`, `audio`, `video`, `dvd`, `bd`, `discimage`; + - `protocols`, `filetypes`, `mimetypes`, `filename` (regex); + - `audiocodec`, `audiochannels`, `videocodec`, `videoresolution`, `videoaspect`, **`hdrtype`**. + + Правила по stream details работают, только если включено извлечение флагов. Правила вкладываются друг в друга. Плеер по умолчанию задаётся алиасами `videodefaultplayer`/`audiodefaultplayer`. В контекстном меню «Play using…» показываются все подходящие плееры. +- **Fallback внутри VideoPlayer:** при открытии декодера Kodi перебирает HW-кодеки (на Android — MediaCodec (Surface) и MediaCodec) и затем ffmpeg SW. Это делается на уровне декодера, а не движка (по знанию архитектуры, в этом раунде код не открывался). + +### 1.13 Сводная таблица + +| Клиент | Движки | Абстракция | Выбор | Fallback при ошибке | DeviceProfile | +|---|---|---|---|---|---| +| Findroid | Exo, mpv | media3 `Player` (`MPVPlayer : BasePlayer`) | глобальная настройка | нет | один «Direct play all» | +| Wholphin | Exo, mpv (+ext) | media3 `Player` + стаб `SimpleBasePlayer` | настройка + `PREFER_MPV` (HDR→Exo) + per-item | транскод тем же движком | на каждый движок | +| JellyPlay | Exo, mpv, VLC | свой `MediaEngine` + `EngineCapabilities` | вручную, на устройство | нет, сознательно | на каждый движок | +| AFinity | Exo, mpv | media3 `Player` | булева настройка | транскод | (не проверено) | +| Void | Exo, mpv | Compose-view, без общего интерфейса | `AUTO`: HDR/DV→Exo, иначе mpv | нет | (не проверено) | +| Streamyfin | mpv, Exo (TV) | Expo-модули | настройка, mpv по умолчанию | нет | (не проверено) | +| Swiftfin | AVPlayer, VLC, mpv | `MediaPlayerProxy` + capability-протоколы | глобальная настройка | нет | на каждый движок | +| Plezy | Exo → mpv | плагин с двумя core | Exo по умолчанию, mpv по настройке | **да, автоматически** | n/a (Plex) | +| jellyfin-androidtv | Exo | `PlayerBackend` + `supportsStream` | — | — | — | +| Nova | AVOS (ffmpeg), Android MP | `IMediaPlayer` + `MediaFactory` | настройка декодера | при отсутствии lib | n/a | +| Kodi | VideoPlayer, PAPlayer, внешние | `IPlayer` + rules | правила (codec/res/hdrtype) + «Play using» | HW→SW внутри VideoPlayer | n/a | + +--- + +## 2. Дизайн абстракции + +### 2.1 Вариант A: каждый движок реализует media3 `Player` +**Плюсы** +- MediaSession, MediaController, уведомления и Google Assistant/«Now playing» на TV работают даром. +- Состояния из media3-ui-compose (`PlayPauseButtonState`, `ProgressStateWithTickInterval` и т. д.) и `PlayerSurface` работают с любым движком. +- Единая модель слушателей. + +**Минусы, подтверждённые кодом** +- `Player` огромен: Timeline, плейлисты, `Tracks`/`Format`, `TrackSelectionParameters`, громкость, устройство. Реализации для mpv в Findroid, Wholphin и AFinity неполные (`TODO()`/`UnsupportedOperationException`) и падают, если сторонний код вызовет «не тот» метод. Пример — `setVideoSurface(Surface)` вместо `setVideoSurfaceView`. +- В `Player` нет audio delay, subtitle delay, aspect/zoom, deinterlace, passthrough и стилей ASS. Всё это всё равно приходится выносить в свой интерфейс и проверять `as?`, как в AFinity: `is MPVPlayer -> …` встречается десятки раз в `PlayerViewModel.kt`. +- Треки mpv и VLC приходится переупаковывать в `TrackGroup(Format)`. Связь с индексами Jellyfin `MediaStream.Index` теряется или требует хаков. +- `BasePlayer` вручную реализовывать сложно: потоки и согласованность событий. Если идти этим путём, брать только **`SimpleBasePlayer`**: он сам сравнивает состояния и рассылает события, а реализация отдаёт `State` и переопределяет `handle*`. + +### 2.2 Вариант B: собственный `PlayerBackend`/`MediaEngine` +**Плюсы** +- Контракт описан в терминах домена. Треки адресуются индексами Jellyfin `MediaStream`, как в Swiftfin, где `setAudioStream(MediaStream)` удобно связывает с PlaybackInfo, отчётами о прогрессе и сменой потока на сервере. +- Возможности объявлены явно, UI показывает только поддерживаемое. +- Движок легко заменить: интерфейс маленький. + +**Минусы:** MediaSession и Compose-состояния нужно соединить самостоятельно. + +### 2.3 Рекомендация: вариант B и один `SimpleBasePlayer`-адаптер в ядре +Так устроен jellyfin-androidtv (`PlayerBackend` + `MediaSessionPlayer : SimpleBasePlayer`). Набор capability-интерфейсов взят у Swiftfin. + +```kotlin +// :playback:api +interface PlaybackEngine { + val id: EngineId // EXO, VLC, MPV… + val state: StateFlow // idle/buffering/ready/ended + playWhenReady + val position: PositionSource // currentPositionMs(), bufferedMs, durationMs + val events: Flow // FirstFrame, VideoSize, Error(EngineError), TracksChanged + val tracks: StateFlow // в терминах Jellyfin MediaStream.Index + engine id + fun attach(output: VideoOutput) // SurfaceView-based, см. 2.4 + fun detach() + fun load(request: EngineLoadRequest) // url, headers, startMs, внешние сабы, выбранные индексы + fun play(); fun pause(); fun seekTo(ms: Long); fun setSpeed(x: Float) + fun selectAudio(streamIndex: Int?); fun selectSubtitle(streamIndex: Int?) + fun release() +} +// Опциональные возможности: проверка через `engine as? X`, UI скрывает ненужное +interface AudioDelayCapable { fun setAudioDelay(ms: Long) } +interface SubtitleDelayCapable { fun setSubtitleDelay(ms: Long) } +interface AspectCapable { fun setAspect(mode: AspectMode /* FIT, FILL, ZOOM, 16:9, 4:3, CUSTOM */) } +interface PassthroughCapable { fun setPassthrough(codecs: Set) } +interface SubtitleStyleCapable { fun applyStyle(style: SubtitleStyle) } +interface DeinterlaceCapable { fun setDeinterlace(mode: DeinterlaceMode) } +interface StatsCapable { val stats: StateFlow } // decoder name, dropped frames, hw/sw + +// Плагин движка: SPI, по одному на Gradle-модуль +interface EngineProvider { + val id: EngineId + val priority: Int + fun capabilities(device: DeviceCaps): EngineCapabilities // для профиля и выбора + fun evaluate(source: MediaSourceInfo, sel: StreamSelection, device: DeviceCaps): Verdict + fun deviceProfile(device: DeviceCaps, prefs: PlaybackPrefs): DeviceProfile + fun create(context: Context): PlaybackEngine +} +``` + +Ядро (`:playback:core`): +- `PlaybackSessionController` хранит item, mediaSourceId, PlaySessionId, выбранные индексы, задержки, позицию и `generation`. Через него идут вся отчётность в Jellyfin (`/Sessions/Playing*`), выбор движка и переключение. +- `EngineSessionPlayer : SimpleBasePlayer` — **единственная** реализация `Player`. Она транслирует состояние контроллера в `State` (один `MediaItem`, позиция, длительность, playWhenReady, команды) и передаёт `handleSetPlayWhenReady`/`handleSeek`/`handleSetVideoOutput` текущему движку. На этом адаптере строятся `MediaSession`/`MediaSessionService` и Compose-состояния. +- ExoPlayer-движок держит `ExoPlayer` внутри. Ему MediaSession «из коробки» не нужна: сессия всегда смотрит на адаптер, поэтому при смене движка её не надо пересоздавать. Wholphin и AFinity, напротив, пересоздают сессию при новом `Player`. + +### 2.4 Видеоповерхность +- **Только `SurfaceView`.** HDR-вывод и tunneling работают лишь с SurfaceView. TextureView проходит через композицию GPU: HDR теряется, tunneling не поддерживается, расход энергии выше. Wholphin явно использует `SURFACE_TYPE_SURFACE_VIEW`. +- **Compose:** `PlayerSurface(player = engineSessionPlayer, surfaceType = SURFACE_TYPE_SURFACE_VIEW)` вызывает `Player::setVideoSurfaceView`/`clearVideoSurfaceView` (проверено в исходнике `media3/ui/compose/PlayerSurface.kt`, ветка release; media3 1.11.1 — последняя на 12.10.2026). У `SimpleBasePlayer` это приходит в `handleSetVideoOutput(Object)`, а адаптер передаёт `SurfaceView` текущему движку: + - Exo: `setVideoSurfaceView`; + - libVLC: `IVLCVout.setVideoView` (или `VLCVideoLayout` со своей subtitle-поверхностью); + - mpv: `MPVLib.attachSurface(holder.surface)`. +- **Своя обёртка для VLC:** `VLCVideoLayout` создаёт свои SurfaceView, в том числе отдельную для субтитров при direct rendering. Поэтому лучше иметь собственный `@Composable EngineVideoSurface(engine)` через `AndroidView`, который сам решает, какой View отдать движку, а PlayerSurface оставить только для Exo. Объявить в `VideoOutput` sealed-варианты: `SurfaceViewOutput(SurfaceView)` и `ViewGroupOutput(FrameLayout)`. +- **Aspect/zoom:** + - Exo — через `AspectRatioFrameLayout`/`Modifier.resizeWithContentScale` (media3-ui-compose) или `VIDEO_SCALING_MODE`; + - VLC — `MediaPlayer.setAspectRatio`/`setScale`/`setVideoScale`; + - mpv — `video-aspect-override`/`panscan`. + + Всё это закрывается одним `AspectCapable`. +- **Субтитры:** + - Exo рисует `SubtitleView` с CueGroup, ASS — через `io.github.peerless2012:ass-media` (0.5.1 от 11.08.2026, используется в Wholphin и jellyfin-androidtv); + - VLC и mpv рисуют субтитры сами (libass внутри движка). + + В контракте нужен флаг «движок рисует субтитры сам», чтобы ядро не накладывало оверлей поверх. + +### 2.5 Смена движка посреди сессии (ручная и автоматическая) +Порядок, обобщающий Plezy, Wholphin и jellyfin-androidtv `switchBackend`: +1. `generation++`; события старого движка с прежним `generation` игнорируются. Повторные запросы fallback схлопываются флагом `fallbackInProgress`. +2. Запомнить `positionMs` (у конца файла ограничить: `min(pos, duration - 10 s)`), `playWhenReady`, Jellyfin-индексы аудио и субтитров, задержки, скорость. +3. Отправить `/Sessions/Playing/Stopped` для старой PlaySessionId (или Progress с понятной причиной). +4. Старый движок: `detach()` → `release()`. VLC и mpv отпускать до создания нового, чтобы избежать конфликтов поверхности и MediaCodec. На слабых TV-боксах два одновременных HW-декодера 4K часто недоступны. +5. Вызвать `POST /Items/{id}/PlaybackInfo` с **DeviceProfile нового движка**. Получится новая PlaySessionId; DirectPlay или транскод определится заново. +6. Новый движок: `attach(output)` → `load(startMs=pos, …)`. Воспроизвести заново внешние субтитры и настройки (задержки, стиль, passthrough). +7. Отправить `/Sessions/Playing` с новым PlayMethod. Показать ненавязчивый toast «Переключено на VLC: аппаратный декодер не поддерживает VC-1» и записать причину в статистику. +8. Защита от петель: один fallback на item за сессию; если упал и fallback-движок, перейти на серверный транскод (как Wholphin и AFinity), а не обратно. + +--- + +## 3. Gradle-модуляризация + +### 3.1 Предлагаемая структура +``` +:playback:api // Android-library без нативных зависимостей: PlaybackEngine, capabilities, EngineProvider, модели +:playback:core // SessionController, EngineSelector, EngineSessionPlayer (SimpleBasePlayer), MediaSessionService, DeviceCaps (MediaCodecList/Display) +:playback:engine-exo // media3-exoplayer, -hls, decoder-ffmpeg (аудио), ass-media; ExoEngineProvider +:playback:engine-vlc // org.videolan.android:libvlc-all:3.7.7; VlcEngineProvider +:playback:engine-mpv // (позже) libmpv AAR; MpvEngineProvider +:app // flavors: full / lite +``` +- **Регистрация через Hilt multibinding.** В каждом engine-модуле: + ```kotlin + @Module @InstallIn(SingletonComponent::class) + abstract class VlcEngineModule { @Binds @IntoSet abstract fun bind(p: VlcEngineProvider): EngineProvider } + ``` + Ядро получает `Set<@JvmSuppressWildcards EngineProvider>`. Если модуль не подключён, движка просто нет. **Стаб не нужен**: Wholphin он понадобился только потому, что `:app` ссылается на класс `MpvPlayer` напрямую. Альтернатива без DI — `ServiceLoader` с `META-INF/services` (R8 его поддерживает). +- **Flavors по движкам:** + ```kotlin + flavorDimensions += "engines" + productFlavors { + create("full") { dimension = "engines"; isDefault = true } // Exo + VLC + create("lite") { dimension = "engines"; applicationIdSuffix = ".lite" } // только Exo + } + dependencies { + implementation(project(":playback:engine-exo")) + "fullImplementation"(project(":playback:engine-vlc")) + } + ``` + Можно добавить второе измерение `store` (`github`, `play`, `amazon`), как Wholphin делает `default/appstore/firetv`. +- **Изоляция API движков.** Нативный AAR подключается как `implementation` только в engine-модуле; `api` для него не делать, чтобы классы libVLC не протекали в `:app`. Разработчикам, не трогающим VLC, можно быстро собирать `liteDebug`. +- **Конфликт `libc++_shared.so`:** у libVLC, libmpv и ass-media своя копия, нужен `packaging { jniLibs { pickFirsts += "lib/*/libc++_shared.so" } }` (как в Wholphin). Версии libc++ из разных NDK формально несовместимы. Лучше проверить, что `full` с VLC и ass-media не падает; ass-media в VLC-сборке не обязателен, так что риск можно убрать, оставив ass-media только в пути Exo. + +### 3.2 Размеры (измерено по AAR с Maven Central, только arm64-v8a) + +| Артефакт | Дата | `.so` без сжатия | `.so` в deflate | 16 КБ (`LOAD` align) | +|---|---|---|---|---| +| `org.videolan.android:libvlc-all:3.7.7` | 01.10.2026 | **55,3 МБ** (libvlc.so 45,9 + libc++ 9,3) | 24,1 МБ | 0x4000 ✔ | +| `dev.jdtech.mpv:libmpv:1.0.0` (mpv 0.41.0, ffmpeg 8.1) | 06.04.2026 | **25,0 МБ** | 11,6 МБ | 0x4000 ✔ | +| `io.github.abdallahmehiz:mpv-android-lib:0.1.12` | 13.02.2026 | 38,3 МБ (с avfilter и libxml2) | 15,8 МБ | 0x4000 ✔ | + +- **Влияние `useLegacyPackaging`.** При `minSdk ≥ 23` AGP по умолчанию кладёт `.so` в APK без сжатия (`extractNativeLibs=false`), поэтому APK на GitHub или F-Droid вырастет на «несжатую» цифру: около +55 МБ для VLC. Варианты: + - `packaging.jniLibs.useLegacyPackaging = true`: APK примерно +24 МБ, но после установки библиотеки распаковываются; + - для Play (AAB) — download size примерно равен сжатому размеру. +- **ABI.** Нужны per-ABI APK (`splits.abi`) или AAB. **armeabi-v7a обязателен для Android TV**: многие TV-устройства работают в 32-битном userspace на 64-битном CPU (по известным данным, ряд Fire TV Stick и Chromecast with Google TV; не проверено в этом раунде). x86 и x86_64 нужны только для эмулятора. Findroid собирает 4 ABI, Wholphin 3 (+universal), JellyPlay по умолчанию только arm64. +- **16 КБ:** все три AAR уже выровнены по 16 КБ (`-Wl,-z,max-page-size=16384` есть в `libmpv-android/buildscripts/build.sh:51`). AGP ≥ 8.5.1 сам выравнивает несжатые `.so` в zip. + +### 3.3 Dynamic Feature Delivery для нативного движка +- **Технически возможно.** `.so` можно положить в on-demand feature-модуль, загрузка через `SplitCompat`/`SplitInstallHelper.loadLibrary` или ReLinker, зависимые `.so` грузятся вручную по порядку (документация Play on-demand). +- **Практически не годится:** + - работает только при установке из Google Play; при sideload возвращаются `REQUIRES_USER_CONFIRMATION`/`APP_NOT_OWNED`/`PLAY_STORE_NOT_FOUND`; + - для F-Droid, GitHub APK и Amazon Appstore не работает; + - поддержка on-demand на Android TV в документации не описана (не проверено); + - Hilt и feature-модули сочетаются плохо: граф DI идёт `app → feature`, а не наоборот, поэтому нужен reflection или ServiceLoader. + + **Вывод:** использовать flavors `full`/`lite`; DFD откладывается до появления Play-дистрибуции, и тогда только как опция. + +--- + +## 4. libVLC или libmpv в роли fallback-движка + +### 4.1 Сравнение + +| Критерий | libVLC 3.7.x (`libvlc-all`) | libmpv (сторонние AAR) | +|---|---|---| +| Артефакт | **Официальный VideoLAN на Maven Central.** 3.7.6 — 11.09.2026, 3.7.7 — 01.10.2026; 4.0.0-eap29 — 08.07.2026 (libVLC 4 всё ещё EAP). vlc-android активен (push 07.10.2026). | Официального AAR нет. Варианты: `dev.jdtech.mpv:libmpv` (Findroid; релизы 0.5.1 — 07.2025, **1.0.0 — 04.2026**; в репозитории renovate регулярно обновляет зависимости, ffmpeg 9.0.2 от 20.09.2026, но релизов не было с апреля); `io.github.abdallahmehiz:mpv-android-lib` (0.1.12, 02.2026); `wholphin-extensions` (GitHub Packages, нужен токен); Plezy (`edde746/mpv-build`, свои tarball); `mpv-compose` (одна версия 2025). | +| Размер arm64 | 55 МБ без сжатия / 24 МБ сжато | 25 / 11,6 МБ (jdtech) | +| 16 КБ | ✔ | ✔ | +| Android API | Java/Kotlin API: `LibVLC`, `MediaPlayer`, `Media`, `IVLCVout`, `VLCVideoLayout`; треки, `setSpuDelay`/`setAudioDelay`, `setAspectRatio`/`setScale`, события. Стабилен много лет. | `MPVLib`: строковые свойства и команды через JNI плюс observer. Обёртку-плеер пишешь сам (~1600 строк в Findroid/Wholphin). Исторически глобальный синглтон, один экземпляр на процесс. | +| HW-декод | MediaCodec, с direct rendering в Surface | `hwdec=mediacodec` (в GLES-текстуру) или `mediacodec-copy` | +| **HDR на Android TV** | При HW-декоде с direct rendering кадр уходит на Surface с метаданными, телевизор получает HDR10/HLG, как в VLC for Android (по прошлому исследованию; не проверено в этом раунде). DV — только через HEVC base layer или профиль 8.1 на совместимых SoC (не проверено). | В стоковом mpv-android `vo=gpu`/`gpu-next` (GLES), **HDR тонмапится в SDR**: в `MPVView.kt` нет HDR-пути, `mediacodec_embed` по умолчанию отключён. Wholphin (`PREFER_MPV`) и Void (`AUTO`) поэтому отправляют HDR в Exo. Plezy понадобился форк `vo=mediacodec`. | +| Субтитры | libass внутри (подтверждено `strings`), DVB/PGS/VobSub через ffmpeg и spudec, есть отдельная субтитровая поверхность | libass с полным override стилей (`sub-ass-override`), лучший ASS-рендер; PGS/VobSub через libav | +| Exotic SW-декод | ffmpeg внутри (VC-1, WMV, MPEG-2, Hi10P, DivX/Xvid, RV) | то же: ffmpeg (VC-1, PGS, dvdsub подтверждены `strings`) | +| Passthrough | есть (SPDIF/HDMI encoded AudioTrack) | `audio-spdif=…` с `ao=audiotrack` (JellyPlay и Plezy пользуются) | +| Стабильность жизненного цикла | Известные подводные камни: строгий порядок `detachViews` → `stop` → `release`, один `LibVLC` на процесс, нативные падения при быстром release/re-create (из опыта сообщества; не проверено) | Глобальное состояние MPVLib, `vo`/surface attach/detach, баги GLES на конкретных SoC (Tegra + gpu-next → синий экран, Plezy #2010) | +| Перспектива | libVLC 4 сменит API (EAP, сроки неизвестны). При изолированном модуле переход займёт один модуль. | Зависимость от одного стороннего сопровождающего AAR. Сборка своими силами (buildscripts mpv-android) — заметная нагрузка на CI. | + +### 4.2 Рекомендация +**Fallback-движок сейчас — libVLC 3.7.7.** Решающие факторы для клиента, у которого главная цель — **корректный HDR без транскода**: +1. При HW-декоде HDR не деградирует в SDR, и это стоковое поведение. У mpv без форка HDR теряется. +2. Официальный, регулярно обновляемый артефакт и стабильный Java API. +3. Полный набор субтитров, включая ASS, PGS и VobSub, плюс passthrough. + +Цена — около +24–55 МБ на ABI; её компенсирует flavor `lite`. + +**mpv** заложить как будущий `:playback:engine-mpv` для ценителей ASS и аниме и для тонких настроек. Выбирать его автоматически только для SDR-контента с «тяжёлым» ASS. Брать `dev.jdtech.mpv:libmpv` (Maven Central, 16 КБ, используется в Findroid, AFinity и Streamyfin) или собирать самим по `libmpv-android/buildscripts`. + +**libVLC 4:** когда выйдет стабильная версия, добавить `:playback:engine-vlc4` рядом с текущим модулем. Провайдер с большим `priority` вытеснит старый без изменений в ядре. + +--- + +## 5. Алгоритм автоматического выбора движка + +### 5.1 Возможности устройства (`DeviceCaps`, кэшировать на запуск и смену дисплея) +- **`MediaCodecList(REGULAR_CODECS)`:** для каждого mime (`video/avc`, `video/hevc`, `video/x-vnd.on2.vp9`, `video/av01`, `video/dolby-vision`, `video/mpeg2`, `video/wvc1`/`video/vc1`, `video/mp4v-es`) собрать: + - `isHardwareAccelerated` (API 29+) или эвристику по имени (`c2.android.*`/`OMX.google.*` — software); + - `CodecCapabilities.profileLevels` (AVC High10 = `AVCProfileHigh10`, HEVC Main10 / Main10HDR10 / HDR10Plus, VP9 Profile2HDR, AV1 Main10HDR10, DV-профили `DolbyVisionProfileDvheSt` (P8) / `DvheStn` (P5) / `DvavSe` и т. д.); + - `VideoCapabilities.areSizeAndRateSupported(w,h,fps)`. +- **Дисплей:** `Display.getHdrCapabilities().supportedHdrTypes` (до API 34) или `Display.Mode.getSupportedHdrTypes()` (API 34+), а также `isHdrSdrRatioAvailable`. +- **Аудио:** `AudioCapabilities` media3 — `isPassthroughSupported` для AC3, E-AC3, DTS, DTS-HD, TrueHD. +- **Exo-движок:** надёжнее всего спрашивать сам Exo, как jellyfin-androidtv: собрать `Format` из `MediaStream` и проверить `RendererCapabilities.supportsFormat` у видео- и аудиорендереров (`getPlaySupportReport`). Тогда учитываются и extension-декодеры (ffmpeg-аудио, dav1d). + +### 5.2 Входные данные Jellyfin +`MediaSourceInfo.Container`, `MediaStreams[]`: +- **Видео:** `Codec`, `Profile`, `Level`, `BitDepth`, `Width/Height`, `RealFrameRate`, `IsInterlaced`, `VideoRange`, **`VideoRangeType`** (`SDR, HDR10, HDR10Plus, HLG, DOVI, DOVIWithHDR10, DOVIWithHLG, DOVIWithSDR, …`; у новых серверов есть варианты с EL и `DOVIInvalid` — проверить по версии SDK), `DvProfile`, `DvLevel`, `DvBlSignalCompatibilityId`, `RpuPresentFlag`, `ElPresentFlag`, `BlPresentFlag`, `CodecTag` (xvid/divx). +- **Аудио:** `Codec`, `Profile` (DTS-HD MA, TrueHD/Atmos), `Channels`. +- **Субтитры:** `Codec` (`subrip, ass, ssa, webvtt, pgssub, dvdsub, dvbsub, mov_text`), `IsExternal`, `DeliveryMethod`. + +### 5.3 Вердикт по каждому движку +```kotlin +sealed interface Verdict { + data class Direct(val quality: Int, val notes: Set) : Verdict // quality: 100 = идеально + data class Degraded(val quality: Int, val losses: Set) : Verdict // HDR→SDR, ASS упрощён, нет passthrough, SW 4K + data class No(val reasons: Set) : Verdict +} +enum class Loss { HDR_TONEMAPPED, DV_TO_BASE_LAYER, ASS_SIMPLIFIED, NO_PASSTHROUGH, SOFTWARE_DECODE_HEAVY, NO_DEINTERLACE } +``` + +**ExoProvider.evaluate** +1. **Контейнер:** `mkv, webm, mp4, m4v, mov, ts, m2ts, mpegts, avi (AviExtractor), flv, ogg, mp3, flac, wav` → ок. `asf/wmv, rm/rmvb, vob/iso/BDMV, mpeg-ps (частично)` → `No(CONTAINER)`. +2. **Видео:** + - если `supportsFormat` == `FORMAT_HANDLED` по HW (или dav1d для AV1) → ок; + - `h264` с `BitDepth=10` (Hi10P) почти нигде нет в HW → `No(VIDEO_CODEC)`; + - `vc1/wmv3`, `msmpeg4*`, `rv*`, `mpeg4` с packed bitstream (DivX) → обычно `No`; + - `mpeg2video` → по MediaCodecList, на многих TV-SoC есть; + - HEVC выше Level HW-декодера (например, 4K120) → `No`. +3. **HDR:** у Exo потерь нет, если HW-декодер поддерживает профиль, а дисплей — тип HDR (или OS тонмапит сама). + - `DOVI*`: есть DV-декодер нужного профиля → Direct. Нет, но BL совместим (`DvBlSignalCompatibilityId` 1/2/4 → HDR10/SDR/HLG) → `Degraded(DV_TO_BASE_LAYER)` через подмену mime (так делает Plezy `DvBitstreamSanitizer`). P5 без DV-декодера → `No` (цвета будут неправильные). + - `DOVIWithEL` (P7 FEL): Direct только на редких SoC, иначе BL HDR10 → Degraded. +4. **Субтитры** (выбранные по умолчанию или пользователем): + - `subrip/webvtt/mov_text/ttml` → ок; + - `pgssub`, `dvdsub` (VobSub в MKV), `dvbsub` → у media3 есть парсеры, ок (проверить на своих файлах); + - `ass/ssa` → с ass-media ок, без него `Degraded(ASS_SIMPLIFIED)`. +5. **Аудио:** при passthrough-возможностях sink ок. TrueHD/DTS без passthrough → ffmpeg-extension (если в сборке) → ок, иначе `No(AUDIO)`. +6. **Interlaced** (`IsInterlaced`, типично MPEG-2/H.264 из ТВ-записей): Exo деинтерлейс не делает (на части SoC его делает сам декодер) → `Degraded(NO_DEINTERLACE)`. + +**VlcProvider.evaluate** +- Контейнер и кодек → почти всегда ок. +- HDR через HW → Direct, если кодек есть в HW; при SW-декоде HDR → `Degraded(HDR_TONEMAPPED)` (не проверено). +- 4K HEVC 10-bit или Hi10P 1080p на слабом SoC → `Degraded(SOFTWARE_DECODE_HEAVY)` по эвристике «пиксели × fps × битность против класса CPU» (`Build.SUPPORTED_ABIS`, `ActivityManager.isLowRamDevice`, число ядер). +- ASS ок, PGS/VobSub ок, passthrough ок. + +### 5.4 Правило выбора +```kotlin +fun choose(src: MediaSourceInfo, sel: StreamSelection, caps: DeviceCaps, + providers: List, prefs: EnginePrefs): EngineChoice { + prefs.forcedEngineFor(itemId = src.itemId, libraryId = src.libraryId)?.let { return it } // per-item и per-library override + if (prefs.mode is Manual) return EngineChoice(prefs.mode.engine) + + val verdicts = providers.associateWith { it.evaluate(src, sel, caps) } + val direct = verdicts.filterValues { it is Verdict.Direct } + val degraded = verdicts.filterValues { it is Verdict.Degraded } + + // 1. Основной движок (Exo), если он даёт Direct + direct.keys.minByOrNull { it.priority }?.let { return EngineChoice(it.id, reason = "direct") } + + // 2. Конфликт «HDR против совместимости»: HDR важнее мелких потерь + // Exo Degraded(ASS_SIMPLIFIED | NO_DEINTERLACE) лучше, чем VLC с HDR_TONEMAPPED + val ranked = degraded.entries.sortedWith( + compareBy> { lossWeight((it.value as Verdict.Degraded).losses) } + .thenBy { it.key.priority }) + ranked.firstOrNull()?.let { (p, v) -> + // Если лучший вариант теряет HDR, а сервер может транскодировать с HDR (или в DV→HDR10) — спросить политику + if (Loss.HDR_TONEMAPPED in (v as Verdict.Degraded).losses && prefs.preferServerOverHdrLoss) + return EngineChoice(EXO, forceTranscode = true, reason = "keep HDR via server") + return EngineChoice(p.id, reason = v.losses.toString()) + } + // 3. Никто не может → транскод основным движком (профиль Exo) + return EngineChoice(EXO, forceTranscode = true, reason = "no local engine") +} +// Пример весов: HDR_TONEMAPPED=100, DV_TO_BASE_LAYER=30, SOFTWARE_DECODE_HEAVY=40, ASS_SIMPLIFIED=20, NO_PASSTHROUGH=15, NO_DEINTERLACE=10 +``` +**Порядок вызовов с сервером:** +1. Решение принимается **до** `PlaybackInfo` по `MediaSources`/`MediaStreams` элемента (`/Items/{id}` с `Fields=MediaSources,MediaStreams` или результат предварительного PlaybackInfo). +2. Затем `PlaybackInfo` вызывается с **DeviceProfile выбранного движка**. Так делают Wholphin, Swiftfin и JellyPlay; объединять профили нельзя, иначе сервер разрешит DirectPlay того, что Exo не умеет. +3. Если сервер вернул транскод для профиля Exo, а VLC мог бы играть напрямую, сменить выбор и повторить PlaybackInfo с профилем VLC: прямое воспроизведение лучше транскода. +4. **Смена аудио- или субтитр-трека пользователем** запускает выбор заново. Пример: включили PGS в HDR-фильме, Exo справляется, остаёмся на нём; включили сложный ASS при SDR — можно предложить VLC или mpv с переключением как в 2.5. + +### 5.5 Runtime-fallback (по образцу Plezy) +- **Триггеры:** + - `PlaybackException` с кодами `ERROR_CODE_DECODER_INIT_FAILED`, `ERROR_CODE_DECODING_FAILED`, `ERROR_CODE_DECODING_FORMAT_UNSUPPORTED`/`EXCEEDS_CAPABILITIES`, `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`; + - `onTracksChanged`: есть видеогруппа, но ни одна не выбрана; + - watchdog: `DecoderCounters` показывают вход без выхода дольше N с; + - 0 кадров (`onRenderedFirstFrame` не пришёл) дольше M с при `isPlaying`; + - `AudioSink` init failure после восстановления. +- **Порядок:** + 1. если кадр уже был отрисован, один повтор в Exo (пересоздать декодер, `seekTo(pos)`); + 2. для DV — попытка DV → HDR10; + 3. переход на следующий движок по ранжированию из 5.4; + 4. серверный транскод. + + Не больше одного перехода на движок за item. +- **Наблюдаемость:** записывать в локальный журнал `(item, codec/profile/rangeType, device model/SoC, decoder name, exception code, выбранный fallback)`. По журналу потом уточнять правила 5.3, например заносить в denylist конкретные `codecName` на конкретных SoC. +- **Настройки UI:** + - «Движок: Авто / ExoPlayer / VLC»; + - «Автоматически переключаться при ошибке: вкл»; + - «Предпочитать сохранение HDR (транскод) вместо SW-декода»; + - per-item «Воспроизвести с помощью…» (как `Destination.Playback.backend` в Wholphin и «Play using…» в Kodi); + - per-library override (например, «Аниме → mpv»). + +--- + +## 6. Ссылки (проверены, если не указано иное) +- Findroid: https://github.com/jarnedemeulemeester/findroid — `player/local/.../mpv/MPVPlayer.kt`, `player/local/.../presentation/PlayerViewModel.kt`, `data/.../JellyfinRepositoryImpl.kt` +- Wholphin: https://github.com/damontecres/Wholphin — `app/.../services/PlayerFactory.kt`, `app/.../ui/playback/PlaybackViewModel.kt`, `app/.../util/mpv/MpvDeviceProfile.kt`, `wholphin-mpv-stub/`, `app/build.gradle.kts` +- wholphin-extensions: https://github.com/damontecres/wholphin-extensions (v0.2.4, 11.09.2026) +- JellyPlay: https://github.com/raulshma/jellyplay — `docs/player-engines.md`, `shared/core/player-contract/.../engine/MediaEngine.kt`, `shared/core/network/.../DeviceProfileProvider.kt`, `.../engine/Media3PlayerHost.kt` +- AFinity: https://github.com/MakD/AFinity — `app/.../ui/player/PlayerViewModel.kt` +- Void: https://github.com/hritwikjohri/Void-for-jellyfin — `domain/model/playback/PlayerType.kt`, `presentation/ui/screen/player/VideoPlayerScreen.kt` +- Streamyfin: https://github.com/streamyfin/streamyfin — `utils/atoms/settings.ts`, `modules/mpv-player`, `modules/exoplayer-player` +- Swiftfin: https://github.com/jellyfin/Swiftfin — `Shared/Objects/VideoPlayerType/VideoPlayerType.swift`, `Shared/Objects/MediaPlayerManager/MediaPlayerProxy/*.swift` +- Plezy: https://github.com/edde746/plezy — `android/app/.../exoplayer/ExoPlayerPlugin.kt`, `ExoPlayerCore.kt`, `VideoDecoderRecoveryPolicy.kt`, `.../mpv/MpvPlayerCore.kt`, `mpv-build.lock.json` +- jellyfin-androidtv: https://github.com/jellyfin/jellyfin-androidtv — `playback/core/src/main/kotlin/backend/PlayerBackend.kt`, `BackendService.kt`, `playback/media3/session/.../MediaSessionPlayer.kt` +- Just Player: https://github.com/moneytoo/Player — `Prefs.java` (`decoderPriority`) +- Nova: https://github.com/nova-video-player/aos-MediaLib — `IMediaPlayer.java`, `MediaFactory.java`; https://github.com/nova-video-player/aos-AVP (`exoplayer.xml`) +- Kodi: https://github.com/xbmc/xbmc/blob/master/system/playercorefactory.xml, `xbmc/cores/playercorefactory/PlayerSelectionRule.cpp`; https://kodi.wiki/view/External_players (403 при загрузке, не проверено) +- media3 PlayerSurface: https://github.com/androidx/media/blob/release/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/PlayerSurface.kt +- libmpv-android: https://github.com/jarnedemeulemeester/libmpv-android (`buildscripts/include/depinfo.sh`, `build.sh`) +- mpv-android: https://github.com/mpv-android/mpv-android (`app/src/main/java/is/xyz/mpv/MPVView.kt`) +- Maven Central: https://repo1.maven.org/maven2/org/videolan/android/libvlc-all/ , https://repo1.maven.org/maven2/dev/jdtech/mpv/libmpv/ , https://repo1.maven.org/maven2/io/github/abdallahmehiz/mpv-android-lib/ +- ass-media: https://repo1.maven.org/maven2/io/github/peerless2012/ass-media/ (0.5.1) +- Play Feature Delivery on-demand: https://developer.android.com/guide/playcore/feature-delivery/on-demand diff --git a/docs/research/jellyfin-client-research.md b/docs/research/jellyfin-client-research.md new file mode 100644 index 0000000..a612e89 --- /dev/null +++ b/docs/research/jellyfin-client-research.md @@ -0,0 +1,1693 @@ +# Исследование: нативный Kotlin-клиент Jellyfin для Android и Android TV на libVLC + +Дата сбора: 2026-10-11. Документ собран из шести параллельных исследований (веб-поиск, чтение исходников, Maven/GitHub API). Все ссылки проверялись на доступность; непроверенные помечены «(не проверено)». + +## Содержание + +1. [Jellyfin API, официальные SDK, протокол](#часть-1-jellyfin-api-официальные-sdk-и-протокольная-документация) +2. [Существующие open-source клиенты: что заимствовать](#часть-2-обзор-open-source-jellyfin-клиентов-и-смежных-проектов) +3. [libVLC на Android: артефакты, API, субтитры, стриминг, лицензии](#часть-3-libvlc-на-android-как-движок-воспроизведения) +4. [Android-платформа: Compose for TV, медиа-интеграция, дистрибуция](#часть-4-android-платформа) +5. [Доменный слой воспроизведения: DeviceProfile, транскодирование, субтитры, изображения, тестовые медиа](#часть-5-доменный-слой-воспроизведения) +6. [Инструментарий разработки: сборка, эмуляторы Android TV с пультом, просмотр UI](#часть-6-инструментарий-разработки) +7. [Гибридный движок воспроизведения, тестовый ТВ, план прототипа](#часть-7-гибридный-движок-воспроизведения-дополнение-от-2026-10-12) + +## Ключевые выводы (сводка) + +- **Сервер и SDK.** Актуальный сервер — Jellyfin 12.2 (октябрь 2026, схема версий сменилась с 10.x на 12.x). Официальный `jellyfin-sdk-kotlin` 1.9.2 (LGPL-3.0, Ktor) покрывает API, discovery и WebSocket; использовать его как есть. С 12.0 legacy-авторизация выключена: только заголовок `Authorization: MediaBrowser ...` и query-параметр `ApiKey` там, где заголовок невозможен (URL для libVLC). +- **libVLC.** Артефакт `org.videolan.android:libvlc-all:3.7.7` (обновляется ежемесячно; 4.0 только EAP). Нативные библиотеки 40–63 МБ на ABI, поэтому только AAB или ABI-splits. Произвольных HTTP-заголовков нет, TLS через GnuTLS (нужен обработчик `Dialog`), HLS-seek слабый, Dolby Vision без RPU. Сильные стороны: ASS/PGS/VobSub рендерит сам, passthrough, любые контейнеры. +- **Референсы.** Ни один живой Kotlin-клиент не использует libVLC как основной движок; официальный Android TV клиент удалил его в 2024 из-за плохого управления жизненным циклом и размера APK. Лучшие образцы: Swiftfin (DeviceProfile для VLC), jellyfin-androidtv (архитектура playback-модулей, профиль под Android-железо), Findroid (`BasePlayer` поверх стороннего движка), Wholphin (Compose for TV без Leanback). +- **UI для TV.** Leanback официально deprecated, для нового проекта только Compose for TV (`androidx.tv:tv-material:1.1.0`, stable). TV-специфичные lazy-списки удалены, используются обычные `LazyRow/LazyColumn` с `BringIntoViewSpec`. libVLC можно обернуть в media3 `SimpleBasePlayer`/`BasePlayer` и отдать в `MediaSession` для Now Playing, PiP и медиакнопок; готовые шаблоны есть в jellyfin-androidtv и Findroid. +- **Лицензия.** Бинарный libVLC де-факто GPLv3-совместим, поэтому для приложения брать GPL-2.0-or-later или GPL-3.0, не GPL-2.0-only. Имя «Jellyfin» и логотип в названии и иконке использовать нельзя. +- **Инструменты.** Android Studio 2026.2.1, AGP 9.4, Gradle 9.8, Kotlin 2.4, Compose for TV `tv-material 1.1.0`. Android TV эмулятор управляется стрелками клавиатуры и `adb shell input keyevent`; рецепт запуска из командной строки в части 6. + +--- + +# Часть 1. Jellyfin API, официальные SDK и протокольная документация + +_Исходный заголовок: Jellyfin: API сервера, официальные SDK и протокольная документация — отчёт для нового Kotlin-клиента (Android + Android TV, libVLC) + +Дата исследования: 2026-10-11. Все URL ниже были проверены (HTTP 200 / содержимое прочитано), кроме помеченных «(не проверено)». + +## 0. Самое важное — контекст версий на октябрь 2026 + +| Что | Версия / дата | Источник | +|---|---|---| +| Jellyfin Server **12.2** (текущий stable) | 05.10.2026 | [GitHub release v12.2](https://github.com/jellyfin/jellyfin/releases/tag/v12.2) | +| Jellyfin 12.1 | 15.09.2026 | [GitHub release v12.1](https://github.com/jellyfin/jellyfin/releases/tag/v12.1) | +| Jellyfin **12.0** (бывший «10.12», смена схемы версий) | 07.09.2026 | [Release post 12.0](https://jellyfin.org/posts/jellyfin-release-12.0/) | +| Jellyfin 10.11.0 | 19.10.2025 | [Release post 10.11.0](https://jellyfin.org/posts/jellyfin-release-10.11.0/) | +| Jellyfin 10.10.0 | 26.10.2024 | [Release post 10.10.0](https://jellyfin.org/posts/jellyfin-release-10.10.0/) | +| Jellyfin 10.9.0 | 11.05.2024 | [Release post 10.9.0](https://jellyfin.org/posts/jellyfin-release-10.9.0/) | +| jellyfin-sdk-kotlin **1.9.2** (API 12.2.0, min server 12.0.0) | 06.10.2026 | [Releases](https://github.com/jellyfin/jellyfin-sdk-kotlin/releases), Maven Central metadata | +| jellyfin-sdk-kotlin 1.8.12 (ветка для 10.11.x; min 10.11.0) | 26.07.2026 | там же | + +Ключевые следствия для нового клиента: +1. **Legacy-авторизация выключена по умолчанию с 12.0** (`X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`, query `api_key`, префиксы `/emby/*` и `/mediabrowser/*` удалены). Нужно использовать только `Authorization: MediaBrowser ...` и query-параметр `ApiKey` (с большой буквы) там, где заголовок невозможен (например, URL для libVLC). +2. HLS-эндпоинты (`master.m3u8` и др.) с 12.0 **скрыты из OpenAPI** — их нет в сгенерированном SDK; нужно использовать `MediaSourceInfo.TranscodingUrl` из ответа `PlaybackInfo`. +3. SDK 1.9.x несовместим с серверами ≤10.11; если хотите поддерживать 10.11 — придётся собирать на 1.8.x или писать свой клиент поверх OpenAPI. +4. В SDK 1.9 (под API 12) **переименованы классы операций** относительно старых примеров: `PlayStateApi.reportPlayback*` → `SessionApi.reportPlayback*`, `ItemsApi` → `LibraryApi` (`getItems`, `getResumeItems`, `getLatestMedia`), `TvShowsApi` → `ShowApi`, `VideosApi` → `VideoApi`, `UserViewsApi` → `UserViewApi`, `DynamicHlsApi` отсутствует. Проверено по Dokka 1.9.x. + +--- + +## 1. Официальная документация API, аутентификация, OpenAPI + +**Важно:** на jellyfin.org **нет** отдельного раздела «API docs» (`/docs/general/server/api/` → 404). Официальные источники по API — это: интерактивный ReDoc на api.jellyfin.org, сам OpenAPI JSON, документация SDK (kotlin-sdk.jellyfin.org / typescript-sdk.jellyfin.org), release-посты и gist мейнтейнера SDK Niels van Velzen. + +- [api.jellyfin.org (ReDoc)](https://api.jellyfin.org/) — официальный интерактивный справочник API (рендерится из OpenAPI JS'ом, поэтому curl и простая загрузка страницы видят только заголовок страницы). Ссылка на него стоит в официальном contributing-гайде как «API documentation». +- [OpenAPI JSON stable](https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json) — актуальная спецификация stable-сервера (сейчас 12.2.0, ~1.9 МБ, 294 пути). **Gotcha:** с CDN api.jellyfin.org скачивание у меня обрывалось на 14 КБ; надёжнее брать с зеркала ниже. +- [OpenAPI JSON unstable](https://api.jellyfin.org/openapi/jellyfin-openapi-unstable.json) — спецификация master-ветки. +- [Архив OpenAPI по версиям](https://repo.jellyfin.org/files/openapi/stable/) — файлы `jellyfin-openapi-<версия>.json` для каждого релиза: от 10.9.0 через 10.10.7, 10.11.0…10.11.11 до 12.0, 12.1, 12.2 (например, [jellyfin-openapi-12.2.json](https://repo.jellyfin.org/files/openapi/stable/jellyfin-openapi-12.2.json), [jellyfin-openapi-10.11.0.json](https://repo.jellyfin.org/files/openapi/stable/jellyfin-openapi-10.11.0.json)). Именно это «версионирование API по релизам сервера»: отдельного номера версии API нет, `info.version` = версии сервера. Удобно для diff'ов между версиями (см. раздел 7). Есть и каталог [unstable](https://repo.jellyfin.org/files/openapi/unstable/). +- [Gist Niels van Velzen «Jellyfin authorization»](https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f) — де-факто официальное описание схемы авторизации (на него ссылается release-пост 10.11). Схема `MediaBrowser`, параметры через запятую, значения в двойных кавычках и URL-encoded, ключи регистрозависимые, неизвестные ключи игнорируются. Перечисляет устаревшие методы (query `api_key`, заголовки `X-Emby-Token`, `X-MediaBrowser-Token`, `X-Emby-Authorization`) и подтверждает их отключение в 12.0. Рекомендует хешировать имя пользователя в `DeviceId`. + +Формат заголовка (из gist + исходника SDK `AuthorizationHeaderBuilder.kt`): +``` +Authorization: MediaBrowser Client="MyApp", Device="Pixel 8", DeviceId="<уникальный id>", Version="1.0.0", Token="" +``` +До логина — тот же заголовок без `Token`. Для API-ключа достаточно `Authorization: MediaBrowser Token=""`. В OpenAPI это security scheme `CustomAuthentication` (apiKey, header `Authorization`). + +- [Исходник серверной проверки авторизации `AuthorizationContext.cs`](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs) — «источник истины»: порядок поиска токена: заголовок `Authorization` → (только при `EnableLegacyAuthorization`) `X-Emby-Token`, `X-MediaBrowser-Token` → query `ApiKey` (всегда) → (legacy) query `api_key`; схема `Emby` принимается только в legacy-режиме. Проверено grep'ом по master. +- [SDK: AuthorizationHeaderBuilder.kt](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/util/AuthorizationHeaderBuilder.kt) — эталонная реализация сборки заголовка (`Client`, `Version`, `DeviceId`, `Device`, `Token`; `encodeURLPart` для значений). +- [Issue #15962 «Default EnableLegacyAuthorization=false breaks Jellyseerr»](https://github.com/jellyfin/jellyfin/issues/15962) — позиция мейнтейнеров: депрекация объявлена в 2023, в 10.11 добавлен opt-in флаг для тестирования, в 12.0 стало opt-out, в следующем мажоре legacy-опция будет удалена совсем; «откатывать не планируем». Временный обход для админов — `EnableLegacyAuthorization` в `system.xml` (см. также [форум-тред](https://forum.jellyfin.org/printthread.php?tid=15085)). +- [Help Net Security о 12.0](https://www.helpnetsecurity.com/2026/09/08/jellyfin-12-0-security-fixes/) — новостная заметка про отключение legacy-логинов и security-фиксы 12.0 (вторичный источник). + +**DeviceId / идентификация клиента** +- Сервер хранит **один access token на `DeviceId`**: если на одном устройстве залогинены два пользователя Jellyfin с одинаковым `DeviceId`, второй логин инвалидирует первый. Рекомендация (gist + сообщество): `DeviceId = hash(installationId + username)` или отдельный id на пользователя. +- `Client`/`Version`/`Device` показываются в дашборде сервера (Devices, Sessions) и влияют на «Playback reporting». +- В SDK это `ClientInfo(name, version)` и `DeviceInfo(id, name)` ([Dokka ClientInfo](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-model/org.jellyfin.sdk.model/-client-info/index.html), [Dokka DeviceInfo](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-model/org.jellyfin.sdk.model/-device-info/index.html)); с 1.9.0 в `DeviceInfo` можно задать язык API (сервер локализует ответы; на сервере с 12.0 поддерживается `Accept-Language`). +- Эндпоинты устройств: `GET /Devices`, `GET /Devices/Info`, `GET/POST /Devices/Options`; разлогин — `POST /Sessions/Logout`; текущий пользователь — `GET /Users/Me`; публичный список пользователей для экрана логина — `GET /Users/Public`. + +**Логин по паролю:** `POST /Users/AuthenticateByName` с телом `{"Username": "...", "Pw": "..."}` (пользователь без пароля — пустая строка) → `AuthenticationResult {User, SessionInfo, AccessToken, ServerId}`; 401 при неверных данных. + +**Quick Connect** (все пути проверены по OpenAPI 12.2): +- `GET /QuickConnect/Enabled` → bool. +- `POST /QuickConnect/Initiate` → `QuickConnectResult {Authenticated, Secret, Code, DeviceId, DeviceName, AppName, AppVersion, DateAdded}`. **`GET /QuickConnect/Initiate` удалён в 12.0** — только POST. +- `GET /QuickConnect/Connect?secret=...` — опрос состояния (SDK рекомендует раз в ~5 с). +- `POST /Users/AuthenticateWithQuickConnect` с телом `{"Secret": "..."}` → `AuthenticationResult`. +- `POST /QuickConnect/Authorize?code=...&userId=...` — вызывается с уже залогиненного устройства (если хотите, чтобы ваш клиент мог *подтверждать* чужие коды). +- [Kotlin SDK guide: Authentication](https://kotlin-sdk.jellyfin.org/guide/authentication.html) — готовые сниппеты для `authenticateUserByName`, Quick Connect, `api.update(accessToken=...)`. +- [Docs: Quick Connect (пользовательская)](https://jellyfin.org/docs/general/server/quick-connect/) — поведение фичи, код из 6 символов, включён по умолчанию, список клиентов, умеющих логиниться/авторизовывать. + +**Неофициальные зеркала (с осторожностью):** +- [mintlify.wiki: jellyfin/api/authentication/overview](https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview) — AI-сгенерированное «зеркало» документации, не официальное; примеры curl корректны, но детали (например, про `api_key`) местами противоречивы. + +--- + +## 2. jellyfin-sdk-kotlin и legacy-репозиторий + +- [github.com/jellyfin/jellyfin-sdk-kotlin](https://github.com/jellyfin/jellyfin-sdk-kotlin) — официальный Kotlin SDK (JVM + Android), сгенерирован из OpenAPI сервера. Лицензия **LGPL-3.0** (совместимо с GPL-приложением при обычном линковании). ~2190 коммитов, активно поддерживается (релизы в сентябре–октябре 2026). +- **Важно про `jellyfin-apiclient-java`:** [github.com/jellyfin/jellyfin-apiclient-java](https://github.com/jellyfin/jellyfin-apiclient-java) теперь **редиректит на jellyfin-sdk-kotlin** — это тот же репозиторий, переименованный; отдельного deprecated Java-клиента больше нет. Старое Java-API было окончательно убрано в SDK v1.7 («Java helper utilities removed», см. миграцию v1.7). +- [kotlin-sdk.jellyfin.org — Getting started](https://kotlin-sdk.jellyfin.org/guide/getting-started.html) — Maven Central, `implementation("org.jellyfin.sdk:jellyfin-core:$sdkVersion")`, DSL `createJellyfin { clientInfo = ClientInfo(name, version); context = androidContext }`, `jellyfin.createApi(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions)`. Указано: Java 8+, «Android 4.4+» (устарело — см. ниже), desugaring для Android < 8. Snapshot-репозиторий `master-SNAPSHOT` на `https://s01.oss.sonatype.org/content/repositories/snapshots/` (работоспособность snapshot-репо не проверена). В разделе «Projects using the SDK» перечислены Findroid и Homedia — можно добавить свой проект PR'ом. +- [Kotlin SDK guide: WebSockets](https://kotlin-sdk.jellyfin.org/guide/websockets.html) — см. раздел 5. +- [Dokka (API reference)](https://kotlin-sdk.jellyfin.org/dokka/) — полный справочник по модулям. +- [Releases](https://github.com/jellyfin/jellyfin-sdk-kotlin/releases) и [v1.9.0](https://github.com/jellyfin/jellyfin-sdk-kotlin/releases/tag/v1.9.0): 1.9.0 (08.09.2026) — под Jellyfin 12.0, **minSdk 21, compileSdk 37**, Kotlin 2.4.20, OkHttp 5.5.0, «no longer compatible with Jellyfin 10.11 and earlier»; 1.9.1 — фикс отмены WebSocket-подписок; 1.9.2 (06.10.2026) — OpenAPI 12.2.0. 1.8.12 (26.07.2026) — бэкпорт для 10.11.x. +- Maven Central: группа `org.jellyfin.sdk`, артефакты (проверено по [repo1.maven.org](https://repo1.maven.org/maven2/org/jellyfin/sdk/)): `jellyfin-core` (+ `-android`, `-jvm`), `jellyfin-api` (+ `-jvm`), `jellyfin-api-okhttp`, `jellyfin-model` (+ `-jvm`), `jellyfin-platform-android`, и устаревший `jellyfin-api-ktor` (последняя 1.7.1). `maven-metadata.xml` для `jellyfin-core`: latest/release = **1.9.2**. +- **Сетевой стек — НЕ Ktor.** [Migrate to v1.7](https://kotlin-sdk.jellyfin.org/migration/v1.7.html): OkHttp 4 стал дефолтом, SDK перестал управлять потоками (вызовы нельзя делать на main thread), `User-Agent` формируется из `ClientInfo`; [Migrate to v1.8](https://kotlin-sdk.jellyfin.org/migration/v1.8.html): пакет `jellyfin-api-ktor` удалён. В 1.9 — OkHttp 5.5. Это удобно: тот же OkHttp можно переиспользовать для картинок (Coil) и при желании настраивать TLS. +- Версии зависимостей (из [gradle/libs.versions.toml](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/gradle/libs.versions.toml) на master): kotlin 2.4.20, kotlinx-coroutines 1.11.0, kotlinx-serialization 1.11.0, okhttp 5.5.0, koin 4.2.2, kotlin-logging 8.0.4, slf4j 2.0.19. +- [HttpClientOptions.kt](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/client/HttpClientOptions.kt): `followRedirects=true`, `requestTimeout=30s`, `connectTimeout=6s`, `socketTimeout=30s`, `socketReconnectPolicy=ExponentialDelayReconnect()`. Явной настройки доверенных сертификатов в опциях нет (не проверено глубже) — для self-signed придётся либо Android Network Security Config, либо смотреть, как подменить OkHttp-клиент. +- [Jellyfin.kt](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/Jellyfin.kt): `Jellyfin.minimumVersion = ServerVersion(12,0,0,0)`, `Jellyfin.apiVersion` = версия OpenAPI, по которой сгенерирован SDK. +- Пакет расширений [org.jellyfin.sdk.api.client.extensions](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.client.extensions/index.html): свойства `api.libraryApi`, `api.mediaInfoApi`, `api.sessionApi`, `api.userDataApi`, `api.showApi`, `api.userViewApi`, `api.videoApi`, `api.audioApi`, `api.imageApi`, `api.subtitleApi`, `api.trickPlayApi`, `api.mediaSegmentApi`, `api.lyricApi`, `api.liveTvApi`, `api.syncPlayApi`, `api.quickConnectApi`, `api.searchApi`, `api.systemApi`, `api.userApi` и т. д.; функции `authenticateUserByName`, `authenticateWithQuickConnect`, generic `api.get/post/delete(pathTemplate, …)` для эндпоинтов, которых нет в генерации (например, HLS). +- Список всех классов операций 1.9: [org.jellyfin.sdk.api.operations](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/index.html) (46 классов: `LibraryApi`, `MediaInfoApi`, `SessionApi`, `ShowApi`, `UserViewApi`, `VideoApi`, `AudioApi`, `ImageApi`, `SubtitleApi`, `TrickPlayApi`, `MediaSegmentApi`, `LyricApi`, `LiveTvApi`, `SyncPlayApi`, `QuickConnectApi`, `AuthenticationApi`, `UserApi`, `UserDataApi`, `SearchApi`, `SystemApi`, `PlaylistApi`, `CollectionApi`, `PersonApi`, `GenreApi`, `StudioApi`, …). +- Полезные Dokka-страницы (проверены): [MediaInfoApi.getPostedPlaybackInfo](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-media-info-api/get-posted-playback-info.html), [SessionApi.reportPlaybackProgress](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-session-api/report-playback-progress.html), [VideoApi.getVideoStreamUrl](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-video-api/get-video-stream-url.html), [AudioApi.getUniversalAudioStreamUrl](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-audio-api/get-universal-audio-stream-url.html), [ImageApi.getItemImageUrl](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-image-api/get-item-image-url.html), [SubtitleApi.getSubtitleUrl](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-subtitle-api/get-subtitle-url.html), [TrickPlayApi.getTrickplayTileImageUrl](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-trick-play-api/get-trickplay-tile-image-url.html), [MediaSegmentApi.getItemSegments](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-media-segment-api/get-item-segments.html), [LibraryApi.getItems](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-library-api/get-items.html), [LibraryApi.getResumeItems](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-library-api/get-resume-items.html), [LibraryApi.getLatestMedia](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-library-api/get-latest-media.html), [ShowApi.getNextUp](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-show-api/get-next-up.html), [UserViewApi.getUserViews](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-user-view-api/get-user-views.html), [SearchApi.getSearchHints](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-search-api/get-search-hints.html), [SessionApi.postFullCapabilities](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-session-api/post-full-capabilities.html), [SyncPlayApi.syncPlayJoinGroup](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-sync-play-api/sync-play-join-group.html), [LyricApi.getLyrics](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-lyric-api/get-lyrics.html), [SystemApi.getPublicSystemInfo](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-api/org.jellyfin.sdk.api.operations/-system-api/get-public-system-info.html). +- DSL для DeviceProfile: [org.jellyfin.sdk.model.deviceprofile](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-model/org.jellyfin.sdk.model.deviceprofile/index.html) — `buildDeviceProfile { directPlayProfile {…}; transcodingProfile {…}; codecProfile {…}; subtitleProfile(...) }`, `buildUpon`. +- Тики: [org.jellyfin.sdk.model.extensions](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-model/org.jellyfin.sdk.model.extensions/index.html) — `Long.ticks: Duration`, `Duration.inWholeTicks`. +- Discovery (jellyfin-core): [org.jellyfin.sdk.discovery](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-core/org.jellyfin.sdk.discovery/index.html), [DiscoveryService](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-core/org.jellyfin.sdk.discovery/-discovery-service/index.html) — `discoverLocalServers(timeout, maxServers): Flow`, `getAddressCandidates(input)`, `getRecommendedServers(input|servers, minimumScore)`. Детали в разделе 6. Отдельной страницы guide по discovery на сайте нет (404); пример есть в Getting started (`jellyfin.discovery.getRecommendedServers(candidates, RecommendedServerInfoScore.GOOD)`). +- Пример проекта: [samples/kotlin-cli](https://github.com/jellyfin/jellyfin-sdk-kotlin/tree/master/samples/kotlin-cli) — CLI с командами discovery, observe (websocket) и т. п. +- Эталонные потребители SDK: [jellyfin-androidtv](https://github.com/jellyfin/jellyfin-androidtv) (официальный TV-клиент на SDK), [jellyfin-android](https://github.com/jellyfin/jellyfin-android), [Findroid](https://github.com/jarnedemeulemeester/findroid) (сторонний нативный Kotlin-клиент на SDK, GPL). +- Для сравнения — [jellyfin-sdk-typescript](https://github.com/jellyfin/jellyfin-sdk-typescript) / [typescript-sdk.jellyfin.org](https://typescript-sdk.jellyfin.org/): таблица совместимости SDK↔сервер (1.0.0 → 12.x, 0.12/0.13 → 10.11.x, 0.11 → 10.10.x, 0.9/0.10 → 10.9.x), тот же паттерн ClientInfo/DeviceInfo/discovery/auth. + +--- + +## 3. Воспроизведение (PlaybackInfo, DeviceProfile, стримы, прогресс, субтитры, trickplay, UserData) + +Единицы времени: **1 tick = 100 нс; 1 с = 10 000 000 ticks**. Все `*Ticks` в API (`RunTimeTicks`, `PositionTicks`, `StartTimeTicks`, `StartPositionTicks`, `PlaybackPositionTicks`, `StartTicks/EndTicks`) — в этих единицах. libVLC оперирует миллисекундами → `ms * 10_000`. + +### PlaybackInfo и DeviceProfile +- `POST /Items/{itemId}/PlaybackInfo` (query: `userId`, `maxStreamingBitrate`, `startTimeTicks`, `audioStreamIndex`, `subtitleStreamIndex`, `maxAudioChannels`, `mediaSourceId`, `liveStreamId`, `autoOpenLiveStream`, `enableDirectPlay`, `enableDirectStream`, `enableTranscoding`, `allowVideoStreamCopy`, `allowAudioStreamCopy`; тело `PlaybackInfoDto` с теми же полями + `DeviceProfile`, `AlwaysBurnInSubtitleWhenTranscoding`). Ответ `PlaybackInfoResponse { MediaSources: [MediaSourceInfo], PlaySessionId, ErrorCode }`. Есть и `GET`-вариант без профиля (бесполезен для решения о транскодировании). +- `MediaSourceInfo` (проверено по спеке 12.2): `Id`, `Container`, `Protocol`, `Path`, `Size`, `Bitrate`, `RunTimeTicks`, `SupportsDirectPlay`, `SupportsDirectStream`, `SupportsTranscoding`, `TranscodingUrl`, `TranscodingSubProtocol` (`http`|`hls`), `TranscodingContainer`, `MediaStreams`, `MediaAttachments`, `DefaultAudioStreamIndex`, `DefaultSubtitleStreamIndex`, `RequiredHttpHeaders`, `IsInfiniteStream`, `LiveStreamId`, `RequiresOpening/OpenToken/RequiresClosing` (Live TV), `HasSegments` (есть media segments), `ETag`. +- Логика выбора: сервер сравнивает `DeviceProfile` с ffprobe-данными файла: контейнер+кодеки проходят `DirectPlayProfiles` и `CodecProfiles` → **DirectPlay** (`/Videos/{id}/stream?static=true`); видео проходит, но контейнер/аудио/субтитры нет → **DirectStream** (ремукс, `TranscodingUrl` c `allowVideoStreamCopy`); иначе **Transcode** (`TranscodingUrl`, обычно HLS). Enum `PlayMethod`: `DirectPlay | DirectStream | Transcode` — его нужно сообщать в прогрессе. +- `DeviceProfile` поля: `Name`, `Id`, `MaxStreamingBitrate`, `MaxStaticBitrate`, `MusicStreamingTranscodingBitrate`, `MaxStaticMusicBitrate`, `DirectPlayProfiles[] {Container, AudioCodec, VideoCodec, Type(Audio|Video|Photo|Subtitle|Lyric)}`, `TranscodingProfiles[] {Container, Type, VideoCodec, AudioCodec, Protocol(http|hls), Context(Streaming|Static), MaxAudioChannels, MinSegments, SegmentLength, BreakOnNonKeyFrames, CopyTimestamps, EnableSubtitlesInManifest, Conditions, EnableAudioVbrEncoding, …}`, `ContainerProfiles`, `CodecProfiles[] {Type(Video|VideoAudio|Audio), Codec, Container, SubContainer, Conditions, ApplyConditions}`, `SubtitleProfiles[] {Format, Method, Language, Container, DidlMode}`. Условия (`ProfileConditionValue`): `Width, Height, VideoBitDepth, VideoBitrate, VideoFramerate, VideoLevel, VideoProfile, AudioChannels, AudioBitrate, AudioSampleRate, AudioBitDepth, RefFrames, IsAnamorphic, IsInterlaced, IsSecondaryAudio, VideoCodecTag, IsAvc, VideoRangeType, NumAudioStreams, NumVideoStreams, NumStreams (10.11+), VideoRotation (12.x)`. Схема DeviceProfile одинакова в 10.10.7 / 10.11.11 / 12.2.0 — можно писать один профиль. +- Для libVLC: он умеет почти всё (MKV/MP4/TS/AVI, H.264/HEVC/VP9/AV1 через MediaCodec или софт, AC3/EAC3/DTS/TrueHD/AAC/FLAC/Opus, PGS/ASS/SRT/VTT), поэтому агрессивный DirectPlay-профиль + HLS/TS fallback (как у официальных клиентов) — разумная стартовая точка. Опирайтесь на реальные профили: + - [jellyfin-androidtv: util/profile/deviceProfile.kt](https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt) — `buildDeviceProfile { … }` с несколькими `transcodingProfile` (TS+HLS, MP4+HLS), `directPlayProfile`, множеством `codecProfile` (HDR-range, AV1/HEVC условия), `subtitleProfile(VTT/WEBVTT, embedded=true, hls=true, external=true)`; рядом `deviceProfileReport.kt` и `codec/` с проверкой MediaCodec-возможностей. + - [jellyfin-android: DeviceProfileBuilder.kt](https://github.com/jellyfin/jellyfin-android/blob/master/app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceProfileBuilder.kt) — профиль для ExoPlayer + отдельный `getExternalPlayerProfile()` (для внешних плееров, по сути «играй всё, maxStreamingBitrate = Int.MAX_VALUE», субтитры EMBED + EXTERNAL) — ближайший аналог того, что нужно для libVLC. + - [jellyfin-android: MediaSourceResolver.kt](https://github.com/jellyfin/jellyfin-android/blob/master/app/src/main/java/org/jellyfin/mobile/player/source/MediaSourceResolver.kt) — как официальный клиент выбирает MediaSource/PlayMethod из ответа PlaybackInfo. + - [jellyfin-web: browserDeviceProfile.js](https://github.com/jellyfin/jellyfin-web/blob/master/src/scripts/browserDeviceProfile.js) и [playbackmanager.js](https://github.com/jellyfin/jellyfin-web/blob/master/src/components/playback/playbackmanager.js) — эталон полного playback-цикла (PlaybackInfo → выбор → отчёты → стоп), включая периодичность прогресса и обработку `TranscodingUrl`. +- [Docs: Codec Support](https://jellyfin.org/docs/general/clients/codec-support/) — таблицы кодеков/контейнеров по клиентам (Android / Android TV) и принцип «Direct Play всё, DirectStream при неподдерживаемом контейнере/аудио/субтитрах, Transcode при неподдерживаемом видео». +- [Docs: Transcoding](https://jellyfin.org/docs/general/post-install/transcoding/) — серверная сторона (HW accel), полезно для понимания причин транскодирования. + +### URL потоков +- Direct play: `GET /Videos/{itemId}/stream` или `/Videos/{itemId}/stream.{container}` с `static=true&mediaSourceId=...&deviceId=...` (+ `tag`/ETag). Полный список query-параметров (проверено по спеке): `container, static, params, tag, deviceProfileId, playSessionId, segmentContainer, segmentLength, minSegments, mediaSourceId, deviceId, audioCodec, videoCodec, enableAutoStreamCopy, allowVideoStreamCopy, allowAudioStreamCopy, audioSampleRate, maxAudioBitDepth, audioBitRate, audioChannels, maxAudioChannels, profile, level, framerate, maxFramerate, copyTimestamps, startTimeTicks, width, height, maxWidth, maxHeight, videoBitRate, subtitleStreamIndex, subtitleMethod, maxRefFrames, maxVideoBitDepth, requireAvc, deInterlace, requireNonAnamorphic, transcodingMaxAudioChannels, cpuCoreLimit, liveStreamId, enableMpegtsM2TsMode, subtitleCodec, transcodeReasons, audioStreamIndex, videoStreamIndex, context, streamOptions, enableAudioVbrEncoding`. +- Transcode/DirectStream: берите готовый `MediaSourceInfo.TranscodingUrl` (относительный путь `/videos/{id}/master.m3u8?...` или `/Videos/{id}/stream?...`, уже содержит `PlaySessionId`, `DeviceId`, `MediaSourceId`, индексы потоков и ключ `ApiKey`; с 10.11 генерируемые URL используют `ApiKey` вместо `api_key`). Склеивайте с `baseUrl`. +- HLS-маршруты существуют, но с 12.0 **скрыты из OpenAPI** (`[ApiExplorerSettings(IgnoreApi = true)]` в [DynamicHlsController.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Controllers/DynamicHlsController.cs)): `GET /Videos/{itemId}/master.m3u8`, `/Videos/{itemId}/main.m3u8`, `/Videos/{itemId}/live.m3u8`, `/Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}`, аналогично `/Audio/{itemId}/master.m3u8`. Релиз-пост 12.0 прямо говорит: «endpoints not in the OpenAPI specification … should not be used» — т. е. формировать их вручную не рекомендуется, используйте `TranscodingUrl`. +- **Gotcha для libVLC:** плееру нужен URL без возможности выставить заголовок `Authorization` → используйте query `ApiKey=` (не legacy `api_key`!). libVLC 3 позволяет задать только User-Agent/referrer-подобные опции, не произвольные заголовки (общее знание, не проверялось в этом исследовании). +- Музыка: `GET /Audio/{itemId}/universal` (`container[]`, `audioCodec`, `maxStreamingBitrate`, `transcodingContainer`, `transcodingProtocol`, `maxAudioSampleRate`, `enableRedirection`, …) — сервер сам решает direct/transcode без PlaybackInfo. Скачивание: `GET /Items/{itemId}/Download`. +- Trailers/Intros: `GET /Items/{itemId}/Intros` (pre-roll), `RemoteTrailers` в `BaseItemDto`. + +### Отчёты о воспроизведении (сессии) +- `POST /Sessions/Playing` — тело `PlaybackStartInfo`; `POST /Sessions/Playing/Progress` — `PlaybackProgressInfo`; `POST /Sessions/Playing/Stopped` — `PlaybackStopInfo`; `POST /Sessions/Playing/Ping?playSessionId=` — keep-alive транскодера. Поля Start/Progress: `ItemId`, `MediaSourceId`, `PlaySessionId`, `PositionTicks`, `IsPaused`, `IsMuted`, `VolumeLevel`, `CanSeek`, `PlayMethod`, `AudioStreamIndex`, `SubtitleStreamIndex`, `RepeatMode`, `PlaybackOrder`, `NowPlayingQueue`, `PlaylistItemId`, `LiveStreamId`, `SessionId`, `PlaybackStartTimeTicks`, `AspectRatio`, `Brightness`. Stop: `ItemId`, `MediaSourceId`, `PlaySessionId`, `PositionTicks`, `Failed`, `NextMediaType`, `LiveStreamId`. Исходник: [PlaystateController.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Controllers/PlaystateController.cs). +- **Старые `POST/DELETE /PlayingItems/{itemId}[/Progress]` (OnPlayback*) депрекированы в 10.11 и удалены из спеки в 12.0** — не используйте. Также `POST/DELETE /Users/{userId}/PlayedItems/{id}` помечены `Obsolete` в коде; актуальные: `POST/DELETE /UserPlayedItems/{itemId}` (`?userId&datePlayed`), `POST/DELETE /UserFavoriteItems/{itemId}`, `GET/POST /UserItems/{itemId}/UserData`, `POST/DELETE /UserItems/{itemId}/Rating`. +- Периодичность прогресса: веб-клиент шлёт прогресс примерно раз в 10 с и при событиях pause/seek/stream switch (значение 10 с — по памяти, не проверено в этом исследовании; смотрите `playbackmanager.js`). Без прогресса сервер через таймаут считает сессию мёртвой и убивает транскодер — отсюда `Ping`. +- **Resume / UserData:** `BaseItemDto.UserData: UserItemDataDto {PlaybackPositionTicks, PlayedPercentage, Played, PlayCount, IsFavorite, Likes, LastPlayedDate, UnplayedItemCount, Rating, Key, ItemId}`. Список «Продолжить просмотр»: `GET /UserItems/Resume?userId=&limit=&mediaTypes=Video&fields=…&excludeActiveSessions=` (пути `/Users/{userId}/Items/Resume`, `/Users/{userId}/Items/Latest` в спеках 10.10.7+ уже отсутствуют — используйте `userId` как query). +- Для «кнопки Resume» нужны `fields=MediaSources,Chapters,Trickplay,MediaStreams` и `enableUserData=true` в запросах. + +### Субтитры +- `SubtitleDeliveryMethod` enum: `Encode` (вжечь), `Embed` (в контейнере, плеер читает сам), `External` (отдельный URL), `Hls` (в манифесте), `Drop`. В `DeviceProfile.SubtitleProfiles` объявляете, какие форматы и каким методом умеете (например, для libVLC: `srt/subrip, ass, ssa, vtt, pgssub, dvdsub, dvbsub` как `Embed`; `srt, ass, ssa, vtt` как `External`). +- Внешний поток: `GET /Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}` и `GET /Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}` (query: `copyTimestamps`, `addVttTimeMap`, `endPositionTicks`). HLS-плейлист субтитров: `GET /Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8`. Загрузка/удаление: `POST /Videos/{itemId}/Subtitles`, `DELETE /Videos/{itemId}/Subtitles/{index}`; поиск онлайн: `GET /Items/{itemId}/RemoteSearch/Subtitles/{language}`, `POST .../Subtitles/{subtitleId}`. Исходник: [SubtitleController.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Controllers/SubtitleController.cs). В ответе PlaybackInfo каждый `MediaStream` типа Subtitle несёт `DeliveryMethod`/`DeliveryUrl`/`IsExternal` (имена этих полей MediaStream не перепроверял по спеке — «не проверено»), что позволяет не собирать URL вручную. В 12.2 графические субтитры отдаются как raw-файлы с Range-поддержкой. +- Для libVLC внешние субтитры удобно подключать через `Media.addSlave(Subtitle, url)`. + +### Trickplay (с 10.9) и главы +- Данные: `BaseItemDto.Trickplay` (нужен `fields=Trickplay`) — словарь `mediaSourceId → width → TrickplayInfoDto {Width, Height, TileWidth, TileHeight, ThumbnailCount, Interval (мс), Bandwidth}` ([Dokka TrickplayInfoDto](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-model/org.jellyfin.sdk.model.api/-trickplay-info-dto/index.html)). +- Картинки-тайлы: `GET /Videos/{itemId}/Trickplay/{width}/{index}.jpg?mediaSourceId=`; HLS image playlist: `GET /Videos/{itemId}/Trickplay/{width}/tiles.m3u8`. Исходник: [TrickplayController.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Controllers/TrickplayController.cs). Индекс тайла = `floor(positionMs / Interval) / (TileWidth*TileHeight)`, позиция внутри — остаток. +- Главы: `BaseItemDto.Chapters[] ChapterInfo {StartPositionTicks, Name, ImageTag, ImagePath, ImageDateModified}` (`fields=Chapters`); картинка главы — `GET /Items/{itemId}/Images/Chapter/{chapterIndex}?tag=`. + +### Media Segments (intro/outro skip, с 10.10) +- `GET /MediaSegments/{itemId}?includeSegmentTypes=Intro&includeSegmentTypes=Outro` → `QueryResult`; `MediaSegmentType`: `Unknown, Commercial, Preview, Recap, Outro, Intro`. `MediaSourceInfo.HasSegments` подсказывает, стоит ли вообще запрашивать. Исходник: [MediaSegmentsController.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Controllers/MediaSegmentsController.cs). Сегменты создают плагины (Chapter Segments Provider, Intro Skipper); сервер лишь хранит. +- [Docs: Media Segments](https://jellyfin.org/docs/general/server/metadata/media-segments) — описание фичи для пользователей/админов, типы сегментов. +- Эталон поведения в клиенте: [jellyfin-androidtv ui/playback/segment](https://github.com/jellyfin/jellyfin-androidtv/tree/master/app/src/main/java/org/jellyfin/androidtv/ui/playback/segment) ([MediaSegmentRepository.kt](https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/ui/playback/segment/MediaSegmentRepository.kt)): действия per type (Skip / Ask to skip / Nothing), минимум 1 с для автоскипа, 3 с для кнопки, автоскрытие кнопки через 8 с. + +--- + +## 4. API библиотеки (browsing) + +Всё проверено по OpenAPI 12.2.0: +- `GET /UserViews?userId=&includeExternalContent=&presetViews=&includeHidden=` — корневые «библиотеки» пользователя (`CollectionType`: movies, tvshows, music, books, …). `GET /UserViews/GroupingOptions`. +- `GET /Items` — универсальный запрос. Ключевые параметры: `userId, parentId, recursive, includeItemTypes[] (BaseItemKind: Movie, Series, Season, Episode, MusicAlbum, Audio, BoxSet, Playlist, …), excludeItemTypes, searchTerm, sortBy[] (ItemSortBy: SortName, DateCreated, PremiereDate, DatePlayed, CommunityRating, Random, Runtime, ProductionYear, SeriesSortName, …), sortOrder[] (Ascending|Descending), filters[] (ItemFilter: IsUnplayed, IsPlayed, IsFavorite, IsResumable, IsFolder, …), fields[] (ItemFields: Overview, Genres, People, MediaSources, MediaStreams, Chapters, Trickplay, ChildCount, DateCreated, ProviderIds, PrimaryImageAspectRatio, Path, …), startIndex, limit, enableTotalRecordCount, enableImages, imageTypeLimit, enableImageTypes[], enableUserData, genres/genreIds, studios/studioIds, years, tags, officialRatings, person/personIds/personTypes, artistIds/albumArtistIds, ids, isPlayed, isFavorite, minCommunityRating, hasSubtitles, isHd, is4K, nameStartsWith/nameStartsWithOrGreater/nameLessThan (алфавитная навигация), collapseBoxSetItems, audioLanguages, subtitleLanguages, mediaTypes`. **Gotcha 12.0:** `recursive` теперь применяется автоматически при фильтрах, но только если задан `includeItemTypes` — результаты могут отличаться от 10.11. +- `GET /Items/{itemId}?userId=` — карточка; `POST /Items/{itemId}` — обновление метаданных; `DELETE`. +- Сериалы: `GET /Shows/NextUp?userId=&seriesId=&parentId=&limit=&enableResumable=&enableRewatching=&nextUpDateCutoff=&fields=`; `GET /Shows/{seriesId}/Seasons`, `GET /Shows/{seriesId}/Episodes?seasonId=&userId=&fields=`; похожие: `GET /Items/{itemId}/Similar` (также `/Movies/{id}/Similar`, `/Shows/{id}/Similar`; в 10.11 добавлены фильтр по тегам и случайная сортировка). +- Новинки: `GET /Items/Latest?userId=&parentId=&includeItemTypes=&limit=&groupItems=&fields=`. Продолжить: `GET /UserItems/Resume` (см. выше). Рекомендации: `GET /Items/Suggestions`. Счётчики: `GET /Items/Counts`. Фильтры для UI: `GET /Items/Filters`, `GET /Items/Filters2` (жанры/теги/годы/рейтинги для выбранной библиотеки). +- Поиск: `GET /Search/Hints?searchTerm=&userId=&includeItemTypes=&limit=&includePeople/Media/Genres/Studios/Artists` (быстрые подсказки, `SearchHint`) **или** `GET /Items?searchTerm=&recursive=true&includeItemTypes=Movie,Series,Episode&fields=…` (полные `BaseItemDto`, то, что делает веб-клиент). +- Коллекции (BoxSet): `POST /Collections?name=&ids=&parentId=&isLocked=`, `POST/DELETE /Collections/{collectionId}/Items?ids=`, содержимое — `GET /Items?parentId={boxSetId}`; `GET /Items/{itemId}/Collections` (новое в 12.x: в каких коллекциях состоит элемент). +- Плейлисты: `POST /Playlists` (тело `CreatePlaylistDto`), `GET/POST /Playlists/{playlistId}`, `GET/POST/DELETE /Playlists/{playlistId}/Items`, `POST .../Items/{itemId}/Move/{newIndex}`, доступы `GET/POST/DELETE /Playlists/{playlistId}/Users[/{userId}]`. В 12.0 плейлисты/коллекции вынесены из родительского элемента в отдельное хранилище (быстрее для больших списков). +- Люди/жанры/студии/годы: `GET /Persons?searchTerm=&personTypes=Actor&appearsInItemId=`, `GET /Persons/{name}`; `GET /Genres`, `GET /Genres/{genreName}`; `GET /Studios`, `GET /Studios/{name}`; `GET /Years`, `GET /Years/{year}`; музыка: `GET /Artists`, `/Artists/AlbumArtists`, `/MusicGenres/{genreName}` — **помечены deprecated в 12.0** (рекомендация релиза: `GetPersons`/`GetGenre`/`GetItems` с типами). Фильтрация по ним — через `/Items?genreIds=…`, `personIds=…`, `studioIds=…`. +- Картинки: `GET /Items/{itemId}/Images/{imageType}[/{imageIndex}]` с query `tag, maxWidth, maxHeight, width, height, fillWidth, fillHeight, quality, format (jpg|png|webp|…), blur, backgroundColor, foregroundLayer, percentPlayed, unplayedCount` (+ `HEAD`). Также «длинный» путь `/Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}`. `ImageType`: `Primary, Art, Backdrop, Banner, Logo, Thumb, Disc, Box, Screenshot, Menu, Chapter, BoxRear, Profile`. Теги берите из `BaseItemDto.ImageTags{type→tag}`, `BackdropImageTags[]`, `ParentBackdropItemId/ParentBackdropImageTags`, `SeriesPrimaryImageTag`, `AlbumPrimaryImageTag`, `ParentLogoItemId/ParentLogoImageTag`, `ParentThumbItemId/ParentThumbImageTag` — `tag` даёт корректное кеширование/инвалидацию. **BlurHash:** `BaseItemDto.ImageBlurHashes {ImageType → {tag → blurhash}}` ([Dokka](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-model/org.jellyfin.sdk.model.api/-base-item-dto/image-blur-hashes.html)). Картинки людей/жанров/студий: `/Persons/{name}/Images/{imageType}`, `/Genres/{name}/Images/{imageType}`, `/Studios/{name}/Images/{imageType}`, `/Artists/{name}/Images/{imageType}/{imageIndex}`. **Gotcha 12.0:** сервер больше не апскейлит картинку выше исходного разрешения. +- Брендинг сервера для экрана логина: `GET /Branding/Configuration` (LoginDisclaimer, SplashscreenEnabled), `GET /Branding/Splashscreen`, `GET /Branding/Css`. +- Live TV (для полноты): `GET /LiveTv/Channels`, `/LiveTv/Channels/{channelId}`, `GET/POST /LiveTv/Programs`, `/LiveTv/Programs/Recommended`, запись/стрим: `/LiveTv/LiveRecordings/{recordingId}/stream`, `/LiveTv/LiveStreamFiles/{streamId}/stream.{container}`; для живого эфира PlaybackInfo с `autoOpenLiveStream=true` возвращает `LiveStreamId`, который надо закрывать (`POST /LiveStreams/Close`, не перепроверял путь). 12.0: Live TV больше не возвращает недостижимые server-local URL. +- Lyrics (10.9+): `GET /Audio/{itemId}/Lyrics` (`LyricDto`, с 10.11 — ELRC-cues по словам), `GET /Audio/{itemId}/RemoteSearch/Lyrics`, `GET /Providers/Lyrics/{lyricId}`; флаг `BaseItemDto.HasLyrics`. + +--- + +## 5. Realtime: WebSocket и удалённое управление + +- Эндпоинт: `ws(s):///socket` (константа `SOCKET_URL = "/socket"` в [DefaultSocketApi.kt](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/jellyfin-api/src/commonMain/kotlin/org/jellyfin/sdk/api/sockets/DefaultSocketApi.kt)). Авторизация — query-параметры (`ApiKey` + id устройства; при отключённом legacy сервер отвечает «Token is required», если прислать `api_key` — см. форум-тред выше). В OpenAPI самого `/socket` нет, но все схемы сообщений описаны (`WebSocketMessage`, `InboundWebSocketMessage`, `OutboundWebSocketMessage` и ~40 конкретных типов). +- Формат сообщения: JSON `{ "MessageType": "", "MessageId": "", "Data": … }`. Enum `SessionMessageType` (12.2): `ForceKeepAlive, GeneralCommand, UserDataChanged, Sessions, Play, SyncPlayCommand, SyncPlayGroupUpdate, Playstate, RestartRequired, ServerShuttingDown, ServerRestarting, LibraryChanged, UserDeleted, UserUpdated, SeriesTimerCreated, TimerCreated, SeriesTimerCancelled, TimerCancelled, RefreshProgress, ScheduledTaskEnded, PackageInstallationCancelled/Failed/Completed, PackageInstalling, PackageUninstalled, ActivityLogEntry, ScheduledTasksInfo, ActivityLogEntryStart/Stop, SessionsStart/Stop, ScheduledTasksInfoStart/Stop, KeepAlive` ([Dokka SessionMessageType](https://kotlin-sdk.jellyfin.org/dokka/jellyfin-model/org.jellyfin.sdk.model.api/-session-message-type/index.html)). +- **KeepAlive:** сервер после подключения присылает `ForceKeepAlive` с `Data` = таймаут в секундах; клиент обязан периодически слать `{"MessageType":"KeepAlive"}` (`InboundKeepAliveMessage`), иначе соединение закроется. В Kotlin SDK это делает `DefaultSocketApi` автоматически, плюс реконнект по `SocketReconnectPolicy.ExponentialDelayReconnect`. +- **Подписки:** периодические данные надо явно запросить: `SessionsStart` с `Data: "initialDelayMs,intervalMs"` (например `"0,1500"`), `SessionsStop`; аналогично `ScheduledTasksInfoStart/Stop`, `ActivityLogEntryStart/Stop`. SDK шлёт start/stop сам при появлении/исчезновении подписчиков (интервал 1 с). +- События, важные для клиента: `UserDataChanged` (`UserDataChangeInfo {UserId, UserDataList[]}` — обновлять прогресс/избранное в UI), `LibraryChanged` (`LibraryUpdateInfo {ItemsAdded, ItemsUpdated, ItemsRemoved, FoldersAddedTo, FoldersRemovedFrom, CollectionFolders, IsEmpty}` — инвалидировать кэш), `ServerRestarting` / `ServerShuttingDown` / `RestartRequired` (показать баннер, переподключаться; HTTP во время рестарта отвечает **503 с `Retry-After`** — введено в 10.11), `RefreshProgress`, `UserUpdated/UserDeleted`. +- **Удалённое управление («Play to» / Cast на ваш клиент):** чтобы ваш клиент появился в списке целей у других клиентов, после логина отправьте возможности: `POST /Sessions/Capabilities?playableMediaTypes=Video,Audio&supportedCommands=…&supportsMediaControl=true&supportsPersistentIdentifier=true` или `POST /Sessions/Capabilities/Full` с телом `ClientCapabilitiesDto {PlayableMediaTypes, SupportedCommands[GeneralCommandType], SupportsMediaControl, SupportsPersistentIdentifier, DeviceProfile, AppStoreUrl, IconUrl}`. Далее по WebSocket приходят: `Play` (`PlayRequest {ItemIds, StartPositionTicks, PlayCommand: PlayNow|PlayNext|PlayLast|PlayInstantMix|PlayShuffle, MediaSourceId, AudioStreamIndex, SubtitleStreamIndex, StartIndex, ControllingUserId}`), `Playstate` (`PlaystateRequest {Command: Stop|Pause|Unpause|NextTrack|PreviousTrack|Seek|Rewind|FastForward|PlayPause, SeekPositionTicks}`), `GeneralCommand` (`GeneralCommandType`: `SetVolume, Mute/Unmute/ToggleMute, SetAudioStreamIndex, SetSubtitleStreamIndex, DisplayMessage, DisplayContent, GoHome, GoToSearch, Back, Select, MoveUp/Down/Left/Right, SetRepeatMode, SetShuffleQueue, SetPlaybackOrder, SetMaxStreamingBitrate, PlayTrailers, ToggleFullscreen, ToggleOsd, SendString, SendKey, …` с `Arguments` map). SDK даёт `api.webSocket.subscribeGeneralCommand(...)`, `subscribePlayStateCommands(...)`, `subscribeSyncPlayCommands(...)`, `subscribe()`. +- **Управление другими сессиями (ваш клиент как пульт):** `GET /Sessions?controllableByUserId=&activeWithinSeconds=`; `POST /Sessions/{sessionId}/Playing?playCommand=PlayNow&itemIds=…&startPositionTicks=…&mediaSourceId=&audioStreamIndex=&subtitleStreamIndex=&startIndex=`; `POST /Sessions/{sessionId}/Playing/{command}` (`PlaystateCommand`, `?seekPositionTicks=`); `POST /Sessions/{sessionId}/Command/{command}` или `POST /Sessions/{sessionId}/Command` (тело `GeneralCommand`); `POST /Sessions/{sessionId}/Message` (`MessageCommand {Header, Text, TimeoutMs}`); `POST /Sessions/{sessionId}/Viewing` («сейчас просматривает»); `POST/DELETE /Sessions/{sessionId}/User/{userId}`. Исходник: [SessionController.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Controllers/SessionController.cs). С 10.11 не-админ может подписываться на свои сессии. +- Заметки релизов: 12.0 — «WebSockets have moved to SDK subscriptions, duplicate `/socket` connections fixed»; 12.1 — закрытие сокета больше не роняет request handler. + +--- + +## 6. Обнаружение серверов в LAN, публичная информация, мульти-сервер, reverse proxy / TLS + +- **UDP discovery:** broadcast на порт **7359/UDP** строки `who is JellyfinServer?`; сервер отвечает JSON `ServerDiscoveryInfo { Address, Id, Name, EndpointAddress }` (модель: [ServerDiscoveryInfo.cs](https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Model/ApiClient/ServerDiscoveryInfo.cs); схема есть и в OpenAPI). Эталон клиента — [LocalServerDiscovery.kt](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/jellyfin-core/src/jvmCommonMain/kotlin/org/jellyfin/sdk/discovery/LocalServerDiscovery.kt): `DISCOVERY_MESSAGE = "who is JellyfinServer?"`, `DISCOVERY_PORT = 7359`, буфер 1024 байта, таймаут 500 мс, максимум 15 серверов, `DatagramSocket` с broadcast. Работает только внутри подсети; на Android нужен `WifiManager.MulticastLock` для надёжного приёма (общая практика, не проверено в SDK). +- [Docs: Networking](https://jellyfin.org/docs/general/post-install/networking/) — порты 8096/TCP (HTTP), 8920/TCP (HTTPS, по умолчанию выключен), 7359/UDP (discovery, не настраивается), 1900/UDP (DLNA — отдельный плагин), замечание о «Known Proxies» и рекомендация терминировать HTTPS на reverse proxy. +- **Публичная информация без авторизации:** `GET /System/Info/Public` → `PublicSystemInfo {LocalAddress, ServerName, Version, ProductName, OperatingSystem, Id, StartupWizardCompleted}` (используйте `Id` как ключ сервера в вашем хранилище, `Version` — для проверки совместимости); `GET /System/Ping` (строка «Jellyfin Server»); `GET /System/Endpoint` (`IsLocal`, `IsInNetwork` — сервер сообщает, считает ли он вас локальным); `GET /Branding/Configuration`; `GET /Users/Public`. Полный `GET /System/Info` требует авторизации. +- **«Умный» подбор адреса (SDK):** [AddressCandidateHelper.kt](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/jellyfin-core/src/jvmCommonMain/kotlin/org/jellyfin/sdk/discovery/AddressCandidateHelper.kt) — из пользовательского ввода (`192.168.1.5`, `host`, `https://host/jellyfin`) строит кандидаты: добавляет `http://`/`https://`, порты 8096/8920, сохраняет base path; [RecommendedServerDiscovery.kt](https://github.com/jellyfin/jellyfin-sdk-kotlin/blob/master/jellyfin-core/src/commonMain/kotlin/org/jellyfin/sdk/discovery/RecommendedServerDiscovery.kt) — параллельно дёргает `/System/Info/Public`, измеряет `responseTime`, присваивает `RecommendedServerInfoScore` GREAT/GOOD/OK/BAD по версии (`< minimumServerVersion` → OK с issue `UnsupportedServerVersion`; устаревшая, но поддерживаемая → GOOD; HTTPS предпочтительнее HTTP) и возвращает `RecommendedServerInfo {address, responseTime, score, issues, systemInfo}`. Эта логика стоит копирования даже без SDK. +- **Мульти-сервер / мульти-пользователь:** в API нет понятия «аккаунта» — клиент сам хранит список `(serverId, baseUrl[, альтернативные URL LAN/WAN], userId, accessToken, deviceId)`. `AuthenticationResult.ServerId` и `PublicSystemInfo.Id` одинаковы — используйте для дедупликации одного сервера, найденного по разным адресам (так делают официальные клиенты). Для нескольких пользователей на одном устройстве используйте разные `DeviceId` (один токен на DeviceId). Токены не истекают сами, но могут быть отозваны в дашборде (с 12.1 отзыв устройства разлогинивает сессии) — на 401 показывать повторный логин. +- [Docs: Reverse Proxy](https://jellyfin.org/docs/general/post-install/networking/reverse-proxy/) — требования, влияющие на клиент: прокси должен пропускать **WebSocket** (`/socket`), корректно пробрасывать `X-Forwarded-For/Proto/Host` (иначе сервер считает всех «удалёнными» и может резать битрейт/доступ), поддержка base URL (например `https://example.com/jellyfin` — ваш клиент обязан уметь путь в baseUrl), HTTP/3 опционален. Все остальные гайды (Caddy/Nginx/Traefik/HAProxy/Apache) — подразделы этой страницы. +- **Self-signed сертификаты:** Jellyfin сам может слушать 8920 с PKCS#12-сертификатом, но документация настойчиво рекомендует TLS на прокси. На Android 7+ пользовательские CA не доверяются приложениям по умолчанию — нужен `network_security_config.xml` с `` и/или собственный trust manager в OkHttp (SDK использует OkHttp; как подменить `OkHttpClient` в SDK 1.9 — не проверено). Также учитывайте cleartext HTTP (`usesCleartextTraffic`) — большинство домашних серверов работают по http://. +- Поведение при старте/рестарте сервера: HTTP **503 + `Retry-After` (секунды)** — клиенту следует ретраить, а не показывать «сервер недоступен» (release notes 10.11). + +--- + +## 7. Прочие полезные документы: фичи, клиенты, сообщество, release notes и ломающие изменения + +**Фичи** +- [Docs: Media Segments](https://jellyfin.org/docs/general/server/metadata/media-segments) — см. раздел 3. +- [Docs: Quick Connect](https://jellyfin.org/docs/general/server/quick-connect/) — см. раздел 1. +- [Docs: Codec Support](https://jellyfin.org/docs/general/clients/codec-support/) — таблицы по клиентам. +- [Docs: Live TV](https://jellyfin.org/docs/general/server/live-tv/) и [Live TV Setup Guide](https://jellyfin.org/docs/general/server/live-tv/setup-guide/) — тюнеры (HDHomeRun, M3U), гайды (XMLTV, Schedules Direct); клиентской части там нет, API — см. раздел 4. +- [Docs: Music (lyrics)](https://jellyfin.org/docs/general/server/media/music/) — форматы `.lrc/.elrc/.txt`, синхронизированные/несинхронизированные тексты. +- [Docs: Books](https://jellyfin.org/docs/general/server/media/books/) — в 12.0 книги/комиксы стали нативными (плагин Bookshelf депрекирован). +- Отдельных страниц документации по **SyncPlay** и **Trickplay** на jellyfin.org нет (проверил несколько путей — 404). Источники: OpenAPI (тег SyncPlay: `POST /SyncPlay/New, Join, Leave, Ping, Ready, Buffering, Pause, Unpause, Seek, Stop, NextItem, PreviousItem, Queue, SetNewQueue, SetPlaylistItem, MovePlaylistItem, RemoveFromPlaylist, SetRepeatMode, SetShuffleMode, SetIgnoreWait`, `GET /SyncPlay/List`, `GET /SyncPlay/{id}` (10.11+)), WebSocket-сообщения `SyncPlayCommand`/`SyncPlayGroupUpdate`, серверный [SyncPlayController.cs](https://github.com/jellyfin/jellyfin/blob/master/Jellyfin.Api/Controllers/SyncPlayController.cs) и эталонная реализация в веб-клиенте [jellyfin-web/src/plugins/syncPlay](https://github.com/jellyfin/jellyfin-web/tree/master/src/plugins/syncPlay) (синхронизация часов через `GET /GetUtcTime` и `SyncPlay/Ping`). Известные грабли: сервер ждёт `Ready` от всех участников до 30 с и ставит группу на паузу ([issue #18121](https://github.com/jellyfin/jellyfin/issues/18121) — упомянут в поиске, содержимое не читал). +- Trickplay: единственная «документация» — [release post 10.9.0](https://jellyfin.org/posts/jellyfin-release-10.9.0/) + контроллер/схемы (раздел 3). В 10.10 добавлено извлечение только по keyframes; в 10.11 trickplay-данные переехали в отдельную подпапку и чистятся при пересканировании. + +**Клиенты и требования к листингу** +- [Downloads → Clients](https://jellyfin.org/downloads/clients/) — только официальные клиенты (Android, Android TV/Fire TV, Desktop/JMP, Kodi/JellyCon, iOS, Roku, WebOS, Tizen, Xbox) с вкладками Recommended/All. +- [Docs: Clients](https://jellyfin.org/docs/general/clients/) — **критерии для попадания стороннего клиента в официальный список**: Jellyfin как first-class (настраиваемый URL), FOSS-лицензия из списка FSF, ≥1 года активной разработки или заметный интерес, хотя бы одна уникальная фича относительно уже перечисленных для платформы, соблюдение community standards и branding; подача — PR в файл данных клиентов в репозитории jellyfin.org (точный путь файла не проверял). +- [Docs: Branding](https://jellyfin.org/docs/general/contributing/branding/) — **важно для именования вашего приложения**: нельзя называть продукт «Jellyfin …», избегать паттернов «Jelly[слово]» и «[слово]fin», нельзя использовать логотип без разрешения; можно писать «X, a Jellyfin client» и использовать цветовой градиент при уникальной форме логотипа. Ассеты — [jellyfin-ux](https://github.com/jellyfin/jellyfin-ux). +- [awesome-jellyfin CLIENTS.md](https://github.com/awesome-jellyfin/awesome-jellyfin/blob/main/CLIENTS.md) — большой список сторонних клиентов. Android: Findroid, Fladder, Jellyflix, AFinity, JellyCine, Linthra, Mellow, Moonfin, Coppelia, Finamp (музыка), Jellify, Fintunes, JellyBook и др.; Android TV: Elefin, Wholphin, Void, Fladder, Moonfin, Ghosten-Player, AfuseKt TV, Plezy, VidHub, Yamby. Полезно для анализа конкурентов и поиска «уникальной фичи» (libVLC-клиентов среди них почти нет). +- [Findroid](https://github.com/jarnedemeulemeester/findroid), [Streamyfin](https://github.com/streamyfin/streamyfin) — крупнейшие сторонние клиенты (Kotlin+SDK и React Native соответственно), полезны как референс поведения. + +**Контрибьюция и сообщество** +- [Docs: Contributing](https://jellyfin.org/docs/general/contributing/) и [Development](https://jellyfin.org/docs/general/contributing/development/) — fork/feature-branch/PR, обсуждение крупных идей в [jellyfin-meta Discussions](https://github.com/jellyfin/jellyfin-meta/discussions), ссылки на style guides и API docs. +- [Contact](https://jellyfin.org/contact/) — Matrix (предпочтительно): `#jellyfin:matrix.org`, `#jellyfin-dev:matrix.org`, **`#jellyfin-dev-client:matrix.org`** и **`#jellyfin-dev-android:matrix.org`** (прямо про сторонние/Android-клиенты), `#jellyfin-troubleshooting`, Space `#jellyfinorg:matrix.org`; Discord-мост `https://discord.gg/zHBxVSXdBV`; форум [forum.jellyfin.org](https://forum.jellyfin.org) (раздел [Development](https://forum.jellyfin.org/f-development)); фичреквесты [features.jellyfin.org](https://features.jellyfin.org) (HEAD вернул 405 — сайт отвечает только на GET, ссылка рабочая). +- README SDK ссылается на Matrix-комнату `+jellyfin-android-dev:matrix.org` (space для Android-разработки). + +**Release notes и ломающие изменения API (10.9 → 12.2)** +- [10.9.0 (2024-05-11)](https://jellyfin.org/posts/jellyfin-release-10.9.0/): Trickplay; корректные HTTP-коды у всех эндпоинтов; удалён EasyPassword (PIN) — остался Quick Connect; DLNA вынесен в плагин; плейлисты приватны по умолчанию. +- [10.10.0 (2024-10-26)](https://jellyfin.org/posts/jellyfin-release-10.10.0/): Media Segments (хранение на сервере, провайдеры — плагины); keyframe-only trickplay; выбор провайдеров лирики; удалены «network paths» в библиотеках (сломало Jellyfin4Kodi); клиенты в основном совместимы. +- [10.11.0 (2025-10-19)](https://jellyfin.org/posts/jellyfin-release-10.11.0/) + [GitHub v10.11.0](https://github.com/jellyfin/jellyfin/releases/tag/v10.11.0): депрекированы `OnPlaybackStart/Progress/Stopped` → `ReportPlayback*` (`/Sessions/Playing*`); добавлен серверный флаг для отключения legacy-авторизации (для тестирования клиентов); **503 + `Retry-After`** при старте/рестарте; `GET /System/Info/Storage`, Backup API, `GET /SyncPlay/{id}`; `ratingScore` для возрастных рейтингов; ELRC-лирика; `parentPrimaryImageItemId` стал Guid; явные дефолты enum'ов в моделях; генерируемые URL используют `ApiKey`; библиотека переехала на EF Core (`jellyfin.db`). Diff OpenAPI 10.10.7→10.11.11, который я сделал локально: удалён только `GET /System/WakeOnLanInfo`; добавлены `/Backup*`, `/SyncPlay/{id}`, `/System/Configuration/Branding`, `/System/Info/Storage`. +- [12.0 (2026-09-07)](https://jellyfin.org/posts/jellyfin-release-12.0/) + [GitHub v12.0](https://github.com/jellyfin/jellyfin/releases/tag/v12.0): смена версионирования (10.12 → 12.0; клиенты, парсящие версии, должны это учитывать); **legacy-авторизация выключена по умолчанию, в т. ч. миграцией на существующих серверах**; удалены префиксы `/emby/*`, `/mediabrowser/*`; удалены `GET /QuickConnect/Initiate` (только POST), `POST /Users/{userId}/EasyPassword`, `GET /Items/{itemId}/CriticReviews`, `GET /Environment/NetworkShares`, `POST /System/MediaEncoder/Path`, `GET /LiveTv/Recordings/Groups/{groupId}`; HLS-контроллеры скрыты из OpenAPI; Swashbuckle v10 (SDK надо регенерировать); `GetItems` асинхронный и меняет семантику `recursive`; `ItemByName`-ответы урезаны, люди дедуплицированы; `UserDto.HasPassword` бесполезен; `Accept-Language`; имена пользователей case-insensitive; картинки не апскейлятся; obsolete (пока работают): `GetTrailers`, `GetArtists/GetAlbumArtists`, `GetArtistByName`, `GetMusicGenre`, instant-mix по жанру, Startup-роуты. Политика: «не используйте эндпоинты/параметры, которых нет в OpenAPI или помеченные obsolete; депрекация держится один мажорный цикл». Мой локальный diff OpenAPI 10.11.11→12.2.0 (25 удалённых операций): все HLS (`/Videos/{id}/master.m3u8|main.m3u8|live.m3u8|hls/*|hls1/*`, `/Audio/{id}/master.m3u8|main.m3u8|hls/*|hls1/*`), `POST/DELETE /PlayingItems/{itemId}`, `POST /PlayingItems/{itemId}/Progress`, `GET /Artists/InstantMix`, `GET /MusicGenres`, `GET /LiveTv/Recordings/Groups`, `GET /LiveTv/Recordings/Series`, `GET /Items/{itemId}/CriticReviews`, `GET /Environment/NetworkShares`, `GET /Tmdb/ClientConfiguration`, `DELETE /Videos/ActiveEncodings`; добавлен `GET /Items/{itemId}/Collections`. +- [12.1 (2026-09-15)](https://github.com/jellyfin/jellyfin/releases/tag/v12.1): только фиксы — отзыв устройства разлогинивает сессии, forced-субтитры перекрывают запомненные, trickplay берёт правильный видеопоток, `/UserViews` не течёт по памяти, `Filters2` возвращает теги, фикс WebSocket. +- [12.2 (2026-10-05)](https://github.com/jellyfin/jellyfin/releases/tag/v12.2): фиксы — Dolby Vision p8 → тег `hvc1`, графические субтитры с Range, исправлено имя типа `BaseItemKind.Channel` (проверьте, если матчите строки типов), Next Up при повторном просмотре, фильтры по людям. +- [State of the Fin 2026-01-06](https://jellyfin.org/posts/state-of-the-fin-2026-01-06) — анонс обсуждения смены версии на 12.0, новый «Experimental» layout веба по умолчанию, тема через CSS-переменные. + +--- + +## 8. Сторонние гайды / статьи («как написать клиент Jellyfin») + +Качественных полноценных туториалов мало; большинство материалов — справочники SDK. Что реально полезно: +- [Gist nielsvanvelzen — Jellyfin authorization](https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f) — лучший короткий текст по авторизации от мейнтейнера SDK (см. раздел 1). +- [Gist kylehowells — «Jellyfin Video Playback.md»](https://gist.github.com/kylehowells/74f538c766a244a3666319860a937030) — неформальная заметка: пример тела `POST /Items/{id}/PlaybackInfo?UserId=` с DeviceProfile, пример ответа с `MediaSources`, `TranscodingUrl` (`/videos//master.m3u8?...`), direct-play `/Videos/{mediaSourceId}/stream.mp4`, внешние субтитры `/Videos//Subtitles/5/0/Stream.vtt`. JSON в примерах «рукописный» (не парсится), версия сервера не указана, но как ориентир по потоку — ок. Последняя активность 06.2025. +- [Kotlin SDK Getting started](https://kotlin-sdk.jellyfin.org/guide/getting-started.html) / [Authentication](https://kotlin-sdk.jellyfin.org/guide/authentication.html) / [WebSockets](https://kotlin-sdk.jellyfin.org/guide/websockets.html) — официальные мини-туториалы; вместе с `samples/kotlin-cli` это фактически «how to write a client». +- [typescript-sdk.jellyfin.org](https://typescript-sdk.jellyfin.org/) — README с пошаговым сценарием: ClientInfo/DeviceInfo → `getAddressCandidates`/`getRecommendedServers` → `createApi` → `getPublicSystemInfo` → `authenticateUserByName` → `getUserViews` → logout. Хороший чеклист для порта. +- [mintlify.wiki Jellyfin API «overview»](https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview) — AI-сгенерированная документация; curl/JS/Python примеры логина корректны, но не считайте её официальной. +- [Jellywatch: What's New in 10.10/10.11](https://jellywatch.app/blog/jellyfin-10-10-10-11-upgrade-guide-new-features-2026), [Jellywatch: 12.0 release](https://jellywatch.app/blog/jellyfin-12-release-everything-you-need-to-know) — сторонний блог; обзорно, местами путаница «10.12 vs 12.0», проверяйте по официальным постам (не проверено содержание детально). +- [deepwiki: jellyfin-meta — OpenAPI endpoint domains & playback lifecycle](https://deepwiki.com/jellyfin/jellyfin-meta/3.2-openapi-specification:-endpoint-domains-and-playback-lifecycle) — AI-сгенерированная wiki по репозиторию; при двух попытках вернула 429, содержимое не проверено. +- [PR mia-cx/pendia #91](https://github.com/mia-cx/pendia/pull/91) — сторонний сервер, реализующий Jellyfin API (PlaybackInfo → DeviceProfile → direct/remux/transcode, `/Sessions/Playing*`); интересен как «обратная» документация того, что ожидают клиенты (октябрь 2026; содержимое PR не читал подробно). +- Сообщество Dart: [dart_jellyfin docs](https://pub.dev/documentation/dart_jellyfin/0.2.0/) — неофициальная библиотека с описанием Quick Connect-потока и замечанием про один токен на DeviceId. + +--- + +## Краткий чеклист практических «граблей» для вашего клиента +1. Только `Authorization: MediaBrowser Client=…, Device=…, DeviceId=…, Version=…, Token=…`; в URL для libVLC — `?ApiKey=`. Никаких `X-Emby-*`/`api_key`. +2. `DeviceId` уникален на пару (установка, пользователь); иначе логины вытесняют друг друга. +3. Решение о способе воспроизведения принимает сервер по `DeviceProfile` в `POST /Items/{id}/PlaybackInfo`; используйте `TranscodingUrl` как есть, `PlaySessionId` — во всех отчётах и в `Ping`. +4. Все времена — тики по 100 нс; `PositionTicks = ms * 10_000`. +5. Отчёты: `POST /Sessions/Playing`, `/Progress` (периодически + при pause/seek), `/Stopped` (обязательно, иначе сессия «висит» и транскодер не освобождается). +6. Запрашивайте `fields=` явно (`MediaSources, MediaStreams, Chapters, Trickplay, Overview, Genres, People, …`) — по умолчанию `BaseItemDto` неполный. +7. Используйте `tag` в URL картинок и `ImageBlurHashes` для плейсхолдеров. +8. WebSocket `/socket`: отвечать `KeepAlive` на `ForceKeepAlive`, отправить `/Sessions/Capabilities(/Full)`, чтобы принимать `Play`/`Playstate`/`GeneralCommand`. +9. Обрабатывайте 503 + `Retry-After` и `ServerRestarting`. +10. Поддержка base path в URL сервера (`https://host/jellyfin`), cleartext HTTP, пользовательские CA (network security config). +11. Название приложения — не «Jellyfin…», не «Jelly*», не «*fin» (branding guide), иначе в официальный список клиентов не попадёте. +12. Для совместимости с 10.11 и 12.x одновременно SDK не подходит (1.8 ↔ 1.9 ломают друг друга); либо целиться только в 12.x с SDK 1.9, либо писать тонкий клиент по OpenAPI и держать совместимость самостоятельно (разница в путях между 10.11.11 и 12.2 — см. список выше). + +Для анализа использовались OpenAPI-спецификации 10.10.7 / 10.11.11 / 12.2.0 и выдержки исходников SDK (в репозиторий не входят). +--- + +# Часть 2. Обзор open-source Jellyfin-клиентов и смежных проектов + +Дата среза: 2026-10-11. Звёзды/даты последнего push взяты из GitHub API в этот день (если не указано иное). Всё, что не удалось подтвердить напрямую, помечено «(не проверено)». + +## 2.0 Главные выводы + +1. **Ни один живой Kotlin-клиент Jellyfin не использует libVLC как основной движок.** Официальный `jellyfin-androidtv` удалил libVLC в июне 2024 (PR #3639, релиз 0.17). Единственный актуальный Kotlin-клиент с libVLC-бэкендом — **JellyPlay** (raulshma/jellyplay, 31★, бета, `libvlc-all 3.7.5`), где VLC — третий «запасной» движок после ExoPlayer и libmpv. Все остальные крупные native-клиенты (Findroid, Wholphin, AFinity, Void, JellyCine) выбрали пару **media3/ExoPlayer + libmpv**. +2. **Лучший референс по DeviceProfile для VLC-движка — Swiftfin** (`Shared/Objects/VideoPlayerType/VideoPlayerType+VLC.swift`, `Shared/Extensions/JellyfinAPI/DeviceProfile.swift`): полные списки direct-play/transcoding/subtitle/codec-профилей для libVLC, режимы совместимости, пользовательские профили. Swiftfin в 2026 перешёл с VLCKit на **SwiftVLC (libVLC 4.0)** и добавил экспериментальный **MPV**-бэкенд (MPVUI). +3. **Лучший референс по DeviceProfile для Android (MediaCodec-зондирование, HDR, passthrough, known-defects по моделям)** — `jellyfin-androidtv/app/src/main/java/org/jellyfin/androidtv/util/profile/` (использует DSL `buildDeviceProfile` из jellyfin-sdk-kotlin) и `jellyfin-android/.../player/deviceprofile/DeviceProfileBuilder.kt`. +4. **Уроки удаления libVLC из jellyfin-androidtv**: (а) устаревший артефакт `libvlc-all 3.5.1` (2022) с незакрытым CVE — F-Droid поднял вопрос (issue #3623); (б) «плохая» реализация JNI-биндингов: объекты не всегда уничтожались → утечки и краши при use-after-destroy; (в) смешанный код ExoPlayer+VLC в одном `VideoManager.java` → проблемы с памятью; (г) размер APK 107 МБ → 24 МБ после удаления. Сейчас на Maven Central есть `org.videolan.android:libvlc-all` **3.7.7** (ветка 3.x) и **4.0.0-eap29** (обновлено 2026-10-01), т.е. причина (а) снята. +5. Пользователи требовали libVLC ради **ASS/SSA и PGS субтитров, DTS-HD/TrueHD, Dolby Vision в MKV**, которые ExoPlayer не тянул. Сегодня официальный клиент закрывает ASS через `io.github.peerless2012:ass-media` (libass для media3) — это альтернатива, которую стоит знать. +6. Брендинг: название «Jellyfin» и логотип — зарегистрированные товарные знаки Jellyfin, Inc.; сторонним приложениям нельзя называться «Jellyfin X», нежелательны «Jelly[word]»/«[word]fin», логотип (и его повороты/вставки) запрещён без разрешения; можно использовать градиент цветов и писать «клиент для Jellyfin» в описании. + +--- + +## 2.1 Официальные проекты Jellyfin + +### jellyfin-androidtv — официальный Android TV-клиент +| | | +|---|---| +| URL | https://github.com/jellyfin/jellyfin-androidtv | +| Лицензия | GPL-2.0 | +| Стек | Kotlin (565 .kt / 36 .java файлов на master), Koin DI, Coil 3, jellyfin-sdk-kotlin 1.8.12 | +| Плеер | media3/ExoPlayer 1.11.1 + `org.jellyfin.media3:media3-ffmpeg-decoder` (аудио DTS/TrueHD и т.п.) + `io.github.peerless2012:ass-media` 0.5.1 (libass). Внешние плееры через Intent: VLC, mpv, MX Player, Vimu (`ui/playback/external/*ExternalPlayerApi.kt`) | +| Цель | только TV (Android TV, Shield, Fire TV), minSdk 23 (с 0.20) | +| UI | гибрид: Leanback 1.2.0 + Compose 1.12.1 (в 0.20 настройки и MainActivity переведены на Compose; 36 XML-layout ещё остались) | +| Статус | активно: 4.6k★, push 2026-10-08; стабильная 0.19.10 (авг 2026), 0.20.0-beta.3 (24 сен 2026), требует сервер 10.11 | + +**История libVLC (проверено по PR/issue):** +- До 0.16.x включительно в `app/.../ui/playback/VideoManager.java` жили оба движка: ExoPlayer и `org.videolan.libvlc.LibVLC/MediaPlayer` (артефакт `org.videolan.android:libvlc-all:3.5.1`). VLC инициализировался опциями `--network-caching`, `--audio-time-stretch`, `--avcodec-skiploopfilter`, `--android-display-chroma`, `--vout=android-opaque,android-display`, опциональный `--video-filter=deinterlace`; субтитры рисовались во второй `SurfaceView` (`setSubtitlesView`), выбор дорожек через `getSpuTracks()/setSpuTrack()`, задержка через `setSpuDelay`. Полезно как список граблей, а не как образец. +- 2023-02-23 PR #2534 «Mark LibVLC as experimental»: «много крашей, потому что наша реализация просто плохая». +- Discussion #2546 (2023-02): мейнтейнер nielsvanvelzen — JNI-биндинги «не всегда корректно уничтожаются» → утечки/краши; старый код playback «end-of-life»; новый playback-модуль стартует только с ExoPlayer; libVLC/mpv «возможно позже, но с нуля, с тестами». +- 2024-06-02 PR #3639 «Remove LibVLC» (релиз 0.17.0), закрывает issue #3623 «CVE-2023-5217 — update libvlc» (поднято из-за F-Droid): последняя стабильная libvlc-jni 3.5.1 от августа 2022, незакрытая CVE; «смешение ExoPlayer и LibVLC могло вызывать проблемы с памятью»; артефакты сборки 107 МБ → 24 МБ. Мейнтейнер: «LibVLC может вернуться, но возможно выберем mpv или FFmpeg». +- Issue #3937 (авг 2024, «вернуть выбор плеера») закрыт как not planned. Форумный тред «SOLVED: Bring back libvlc» — совет ставить 0.16.11 либо внешний VLC/mpv. +- Issue #3888: после удаления гибридные DV/HDR10-файлы дают чёрный экран в ExoPlayer — closed/not planned. + +**Что заимствовать:** +- Архитектуру playback-модулей: `playback/core` (PlaybackManager, PlayerBackend-абстракция, очередь с QueueSupplier/OrderIndexProvider, MediaStreamResolver, TimedEventService), `playback/jellyfin` (JellyfinMediaStreamResolver, PlaySessionService + socket-репортинг, MediaSegmentService, lyrics), `playback/media3/exoplayer` (ExoPlayerBackend, `support/*` — отчёт о поддержке формата, `mapping/*` — маппинг кодеков) и `playback/media3/session`. Это готовый чертёж, куда вместо ExoPlayerBackend подставляется VlcBackend. +- `util/profile/deviceProfile.kt` + `MediaCodecCapabilitiesTest`, `codec/*CodecCapabilities.kt`, `audioPassthrough.kt`, `KnownDefects.kt` (чёрные списки моделей Fire TV с багом DoVi/HDR10+, Shield с нераспознанным DV Profile 7). Профиль учитывает downmix, bitstream AC3/EAC3/DTS/TrueHD (auto-детект passthrough), `assDirectPlay`/`pgsDirectPlay`, пользовательские AVC/HEVC level override, принудительное включение/выключение HDR-форматов, два transcoding-профиля HLS (TS и fMP4) с `enableSubtitlesInManifest`. +- Внешние плееры: `VlcExternalPlayerApi.kt` реализует интенты VLC (`subtitles_location`, `position`, коды результата) — полезно для fallback «открыть в VLC». +- Боли из issues: TV-навигация и фокус на Leanback тяжело поддерживать (поэтому идёт миграция на Compose), нестабильность DV/HDR на Fire TV, отсутствие субтитров нужных форматов в ExoPlayer. + +### jellyfin-android — официальный мобильный клиент +| | | +|---|---| +| URL | https://github.com/jellyfin/jellyfin-android | +| Лицензия | GPL-2.0 | +| Стек | Kotlin, WebView-обёртка jellyfin-web + нативный плеер; Koin 4.2.2, Compose 1.12.1 (частично), jellyfin-sdk 1.8.12 | +| Плеер | media3/ExoPlayer 1.11.1 (+hls, +cast, +ffmpeg-decoder), Chromecast только в proprietary-flavor | +| Цель | телефон/планшет | +| Статус | 2.8k★, push 2026-10-09, активен | + +**Что заимствовать:** `app/src/main/java/org/jellyfin/mobile/player/deviceprofile/DeviceProfileBuilder.kt` — профиль строится динамически по `MediaCodecList`: таблица контейнер→допустимые видео/аудио-кодеки, `CodecProfile` c `VIDEO_PROFILE` из реальных возможностей декодера, набор `FORCED_AUDIO_CODECS` (кодеки от FFmpeg-расширения, которые MediaCodecList не объявляет), отдельный `getExternalPlayerProfile()` (всё direct-play, без transcoding — ровно то, что нужно для VLC-режима «играй всё сам»), профили субтитров EMBED/EXTERNAL с опцией `exoPlayerDirectPlayAss`. Также `player/source/MediaSourceResolver.kt`, `ExternalSubtitleStream.kt`, `QueueManager`, `TrickplayHelper`, `mediasegments/`, `PlayerService`/MediaSession. + +### jellyfin-sdk-kotlin +| | | +|---|---| +| URL | https://github.com/jellyfin/jellyfin-sdk-kotlin (docs: https://kotlin-sdk.jellyfin.org) | +| Лицензия | LGPL-3.0 | +| Стек | Kotlin, Android + JVM; модули `jellyfin-core`, `jellyfin-api`, `jellyfin-model`, `jellyfin-api-okhttp`; код генерируется из `openapi.json` | +| Версия | **1.9.2** на Maven Central (обновлено 2026-10-06); клиенты в основном на 1.8.12 (10.11 SDK) | +| Статус | 125★, push 2026-10-08 | + +**Что заимствовать:** использовать как есть. Есть DSL `org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile { directPlayProfile {…}; transcodingProfile {…}; codecProfile { conditions { … } } }` (видно в jellyfin-androidtv), server discovery, socket API. Binary-compatibility validator — пример дисциплины для публичного API модуля плеера. + +### Swiftfin (iOS/tvOS) — главный референс по VLC-профилям +| | | +|---|---| +| URL | https://github.com/jellyfin/Swiftfin ; docs: https://github.com/jellyfin/Swiftfin/blob/main/Documentation/players.md | +| Лицензия | MPL-2.0 | +| Стек | Swift, SwiftUI, jellyfin-sdk-swift 3.3.0 | +| Плеер | три бэкенда: `VideoPlayerType.avPlayer` (native), `.vlc` — **SwiftVLC 1.0.0 (harflabs/SwiftVLC, libVLC 4.0, MIT)**, `.mpv` — **MPVUI 0.5.0 (LePips/MPVUI)**, пока за флагом `Experimental.mpvPlayer`. Прокси: `Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+{VLC,MPV,AVPlayer}.swift` | +| Цель | iOS 18+, tvOS 26+ | +| Статус | 4.2k★, push 2026-10-10, активен | + +**Что заимствовать (конкретные файлы):** +- `Shared/Objects/VideoPlayerType/VideoPlayerType+VLC.swift`: `_vlcDirectPlayProfiles` (30 аудио- и ~25 видео-кодеков, AV1 только при `PlaybackCapabilities.supportsAV1`), `_vlcTranscodingProfiles` (HLS, `isBreakOnNonKeyFrames`, `maxAudioChannels 8`, `minSegments 2`, контейнер mp4; **отдельный профиль для VC1**, чтобы обойти серверное ограничение `_supportedHlsVideoCodecs`), `_vlcSubtitleProfiles` (embed: ass, ssa, pgssub, dvbsub, dvdsub, subrip, vtt, ttml, mov_text, sami, teletext…; external: subrip, ass, ssa, …; encode/burn-in: dvbsub, dvdsub, pgssub, vtt, xsub — т.е. внешние bitmap-субтитры VLC не грузит, их прожигают), `_vlcCodecProfiles` (условия `videoRangeType` для h264/hevc/av1/vp9, `isAnamorphic`, `isInterlaced`, список HDR-режимов `vlcHDRProfiles`: HLG/HDR10/HDR10+/DoVi-with-*). +- `Shared/Extensions/JellyfinAPI/DeviceProfile.swift`: `DeviceProfile.build(for:compatibilityMode:maxBitrate:maxResolution:)` — режимы `auto / mostCompatible / directPlay / custom`, принудительный burn-in субтитров (`forceSubtitleBurnIn`), методы `canPlay(...)` для проверки direct-play локально; `PlaybackDeviceProfile.swift` — пользовательские профили в UI (`CustomDeviceProfilesView`). +- `MediaPlayerProxy+VLC.swift`: выбор дорожек по индексу `MediaStream.index` ↔ `player.audioTracks/subtitleTracks`, `setAudioDelay`/`setSubtitleDelay`, протоколы `MediaPlayerSubtitleConfigurable`/`MediaPlayerOffsetConfigurable`; `TextSubtitlePresentation.swift` — overlay текстовых субтитров поверх MPV (у VLC субтитры рисует сам движок). +- Документ players.md фиксирует ограничения VLC: нет TLS 1.3 в libvlc, нет DTS-HD/TrueHD/MLP, нет PiP, external-display только зеркалом, повышенное энергопотребление при software-decoding, DV Profile 5 не поддерживается, HDR→SDR tonemapping «точность цвета может плавать». + +### jellyfin-web / jellyfin-vue +- **jellyfin-web** — https://github.com/jellyfin/jellyfin-web, GPL-2.0, JS/TS (webpack+vite), 3.9k★, push 2026-10-11, v13.0.0. Плеер — HTML5 `