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

403 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SleepyOwl для Android TV: устройство приложения, решения и найденные ловушки
SleepyOwl — open-source клиент Jellyfin на Kotlin (GPL-3.0-or-later). Сначала Android TV, рядом — версия для Linux на тех же общих модулях. Цель — воспроизводить всё возможное без серверного транскода, с корректным HDR и Dolby Vision, и при этом быстро работать на слабых ТВ и приставках.
Этот документ — об интерфейсе и прикладном слое ТВ-версии: как устроено, почему так, какие факты о ТВ, Compose и сервере Jellyfin выяснились при работе, сколько что стоит в миллисекундах. Воспроизведение (движки, выбор движка, исправленные экстракторы media3, прокси libVLC, HDR/DV, особенности MediaTek) описано в [`playback.md`](playback.md), сводка приёмов оптимизации интерфейса — в [`ui-performance.md`](ui-performance.md). Контракт ряда «Продолжить просмотр» для своей оболочки ТВ — [`launcher-feed.md`](launcher-feed.md).
**Эталонное устройство** (на нём сделаны все замеры): Xiaomi TV S Pro Mini LED 75 (`MiTV-MOEU1`), MediaTek MT9689, Android 14, **только 32-bit**, 2,4 ГБ RAM, GPU Mali, интерфейс рисуется в 1920×1080 (960×540 dp, плотность 2.0). **Сервер:** Jellyfin 12.x (проверено на 12.2), `jellyfin-sdk-kotlin` 1.9.2; снаружи — за reverse proxy по HTTPS, в LAN — напрямую по HTTP:8096.
---
## 1. Архитектура
### 1.1 Модули
| Модуль | Что внутри |
|---|---|
| `:app` | ТВ-экраны под пульт (`ui/home`, `details`, `player`, `series`, `library`, `search`, `favorites`, `person`, `settings`, `login`, `shell`, `menu`), `MainActivity` (навигация), `SleepyOwlApp`, `LauncherFeed`, Koin-модуль Android, `TextInput` с подъёмом над клавиатурой, `AnchoredScrolling`, панели плеера, `TileArt`, `BackdropTransformation`, список лицензий |
| `:ui:shared` | KMP (android + desktop), Compose Multiplatform 1.12 (на Android это те же androidx Compose 1.12). Все ViewModel'и, навигационные ключи `Screen`, вкладки `Tab`, `ScreenFocus`, `LibraryMemory`, `CardMapper`, подписи (`Words`, `MediaLabels`, `PlayerLabels`, `TrackChoices`), модели панелей плеера (`PanelChoices`), логика пропуска заставок (`SkipPlan`), цель меню «просмотрено» (`WatchTarget`, `clickableWithMenu`), тема (`Tokens`), шрифты, иконки, логотип, кнопки, чипы, карточки, свечение фокуса, `RemoteImage`, `uiModule` (Koin) |
| `:core:common` | `Log` (на Android через Timber), `monotonicMs()`, каталоги и фабрика DataStore |
| `:core:jellyfin` | `JellyfinSession` (аккаунты, токен, `call {}` на IO, `authorizedUrl()`), `ServerLogin` (поиск сервера, проверка адреса, публичные пользователи, пароль, Quick Connect), `LocalServerDiscovery`, `HttpModule` + `ServerRouting` (маршрут на LAN-адрес) |
| `:core:data` | модели без DTO SDK (`ItemSummary`, `ItemDetails`, `LibraryView`, `Person`, `Page`, `ItemQuery`, `MediaSegment` …), `Mapping.kt`, репозитории `LibraryRepository`, `WatchRepository`, `HomeRepository`, `UserSettingsRepository`, `SessionCache`, `ImageUrls`, `SearchHistory` |
| `:playback:api`, `:playback:core` | интерфейсы движков, `PlaybackSession`, `EngineSelector`, `PlaybackPreferences` (вид субтитров, запомненные дорожки, настройки по умолчанию) |
| `:playback:engine-exo`, `:playback:engine-vlc` | движки (только Android) |
| `:baselineprofile` | генерация baseline profile на ТВ (macrobenchmark + UiAutomator) |
Общие модули (`core:*`, `playback:api/core`, `ui:shared`) — Kotlin Multiplatform; `commonMain` компилируется как JVM-код, поэтому `java.time`, `Locale`, ZXing работают в общем коде. DI — Koin 4.2 (движки подключаются через `bind EngineProvider::class` в модуле движка). Платформа отдаёт общему слою `UiPlatform`: плотность экрана (для размера картинок с сервера), строки версии и устройства, доступен ли отладочный обход.
### 1.2 Слой интерфейса
- **Свои компоненты на Compose**, без Leanback и `tv-material` (он убран полностью: APK на 30 КБ меньше, ушла зависимость от `material-icons-core`).
- **Навигация — Navigation 3:** `NavDisplay` + свой стек `Screen` в `MainActivity`; у каждого экрана своё сохранённое состояние и ViewModel. Анимаций переходов нет. Ключи: `Login`, `Root(tab, account, library?)`, `Detail(itemId)`, `Series(seriesId, episodeId?)`, `Person(personId)`, `Player(…)`, `Items(parentId, title)` (сетка коллекций и папок), `License`, `Tour` (debug).
- **`Root` различается по аккаунту.** Иначе после смены профиля оставались главная и ViewModel прежнего аккаунта. Строить через `MainActivity.root(tab)`.
- **ViewModel'и с параметром** — `koinViewModel { parametersOf(id) }` (образец — `DetailsViewModel`).
- **Модели для UI** — неизменяемые (`@Immutable`), без DTO SDK в composable-функциях. Модели карточек строит `CardMapper` вне главного потока.
- **Перезагрузка экрана после изменения данных просмотра** — подписка на `SessionCache.invalidations` (как в `DetailsViewModel`): «ещё N мин» обновляется, выбор дорожек и фокус сохраняются.
- **Запечённые картинки — один интерфейс, две реализации.** На Android код вылизан под Mali (маски `BlurMaskFilter`, углы и затенение запекаются при декодировании); на ПК — простая реализация (Skia-размытие, `clip`, градиент поверх). Разбор BlurHash, QR-код (`argbImageBitmap`) и пунктирная рамка (Canvas Compose) — общие.
- **Ресурсы:** `androidResources { enable = true }` в `android {}` KMP-библиотеки (AGP 9.4) включает обычные Android-ресурсы (класс `ru.stenfredd.sleepyowl.ui.shared.R`); `src/androidMain/res` подключён и к ресурсам ПК, файлы не дублируются.
- **`internal` между модулями не работает:** раскладки (`DetailsLayout`, `PlayerLayout`), `plural`, `PlayerLabels`, запись `ScreenFocus.lastKey` — публичные. Умный каст на свойство из другого модуля не срабатывает — заменять локальной переменной.
- **Koin `verify()`** проверяет и `getAll()` по типу элемента: где движков нет (ПК-тест), `EngineProvider` указывается в `extraTypes`. Граф ТВ проверяет `KoinGraphTest`, граф и отрисовку компонентов ПК — `:ui:shared:desktopTest`.
---
## 2. Ключевые решения и почему
- **Дизайн** — макет «Jellyfin TV» (`design/Дизайн Jellyfin для Android TV/Jellyfin TV.dc.html`, демо-данные в `tv-core.js`; варианты A и B не используются). Открывается в браузере, сверху переключатель экранов. Макет 1920×1080 px, **px из макета ÷ 2 = dp**. Скриншоты макета удобно снимать headless-Chromium (playwright): переключение экранов — клик по кнопке с названием экрана вверху, дальше — Enter и стрелки. Шрифты — Onest (текст) и JetBrains Mono (служебные подписи и цифры), OFL, подмножество латиница + кириллица, в `res/font`.
- **Навигация — боковое меню («рейл»):** узкая полоса иконок, при фокусе раскрывается с подписями. Режим «скрытое меню» не делался. Пункты: Поиск, Главная, Медиатека, Избранное, затем библиотеки сервера, внизу «Настройки» и аватар профиля («сменить профиль»). Часы справа сверху.
- **PIN профилей нет.** Вход — пароль или Quick Connect, дальше хранится токен.
- **Движок, вывод видео и Dolby Vision не выносятся в обычный интерфейс.** Пользователь видит только «Качество» (Оригинал / потолки битрейта — всё ниже оригинала означает транскод) и чип «Direct/Транскод». Движок, вывод видео и DV — блок «Дополнительно» в панели «Информация о потоке» и раздел настроек «Воспроизведение». Отладочный значок, варианты 0–9 и режим обхода — только в debug-сборке.
- **Хранение на ТВ — DataStore** (аккаунты, выбор дорожек, недавние запросы, настройки). Room не используется. Снимок главной — JSON-файл.
- **«Новинка»** — добавлено за последние 14 дней.
- **Языки звука и субтитров — настройки пользователя на сервере** (общие для всех его клиентов Jellyfin); качество по умолчанию, вывод видео, DV, вид субтитров и пропуск заставок — на ТВ, для всех профилей.
- **Выход и «Забыть» отзывают токен на сервере** и забывают профиль на ТВ.
- **Release без обфускации:** проект открытый, теги логов и стеки остаются читаемыми.
- **Весь трафик дома идёт на LAN-адрес сервера** по HTTP (API, картинки, видео), а не через HTTPS-hairpin публичного адреса — см. §5.2. Минус: токен внутри домашней сети идёт открытым текстом.
- **Ряд для своей оболочки ТВ приложение присылает само**, оболочка его не опрашивает — см. [`launcher-feed.md`](launcher-feed.md).
- **Слово «Jellyfin» в интерфейсе — только там, где речь о сервере** («Ищем Jellyfin в локальной сети»). Название и логотип свои.
- **Производительность — главное требование** (§3): если элемент макета заметно тормозит на эталонном ТВ, он упрощается. Все такие отличия от макета перечислены по областям в §5.
---
## 3. Производительность
### 3.1 Что выяснилось о GPU эталонного ТВ
- **Частичная перерисовка выключена производителем** (`debug.hwui.render_dirty_regions=false`): каждый кадр GPU рисует весь экран 1920×1080, каждый видимый элемент платит на каждом кадре, даже неподвижный. Узкое место — GPU, а не CPU и не Compose.
- **Слабое смешивание:** полупрозрачный слой на весь экран — 5–13 мс; сплошная заливка почти бесплатна.
- **Любая текстура на весь экран ~12 мс GPU на кадр** — даже непрозрачная, запечённая в один bitmap и в полном разрешении (растяжение ни при чём).
- **Градиенты на больших площадях дороги** (HWUI их дизерит): затемнения 130 + 230 dp градиентом — ~8 мс; тот же градиент, запечённый и растянутый bitmap'ом, — ~6 мс. Дешевле всего полосы сплошного цвета.
- **Прямоугольник с дробными координатами** рисуется сглаженным, более тяжёлым шейдером — границы ставить по целым пикселям.
- **Тонкие примитивы по всей ширине дороги:** сотни рамок из прямоугольников по 1 px — ~9 мс; скругления, штрихи и число вызовов вторичны.
- **Перепекать bitmap на каждое нажатие тоже плохо** — дорога загрузка текстур (фаза `sync`).
- **Любая непрерывная анимация = перерисовка всего экрана 60 раз в секунду:** плавно пульсирующая точка стоила ~17 мс GPU на кадр.
- **Смена содержимого большой области во время анимации фокуса** делает дорогими все её кадры (~15 мс GPU против 4).
- Ориентир: лаунчер Google TV на том же ТВ — ~5 мс GPU на кадр.
### 3.2 Правила для всего интерфейса
- Фон — только сплошной цвет окна (фон окна = фон приложения). Сетка точек из макета убрана (~5 мс каждого кадра), радиальные свечения фона — тоже.
- **Никаких `Modifier.clip` по скруглённой форме у картинок и затемнений поверх картинок:** углы и затемнение под текстом запекаются в bitmap при декодировании (`CardImageTransformation`). Картинка сначала обрезается под размер карточки, потом скругляется — иначе у постеров не 2:3 запечённые углы срезались и поверх рамки фокуса торчали прямые углы.
- **Никаких `drawPath`** в часто перерисовываемом: иконки растеризуются в bitmap один раз (`Icon`), формы — скруглённые прямоугольники; пунктир запечён (`Modifier.dashedBorder`).
- **Свечение фокуса — готовая картинка** (`GlowCache`: размытый прямоугольник считается один раз на размер), стоит ~0. Только у элемента в фокусе. Никаких `blur`/`RenderEffect` при прокрутке.
- **Составные картинки** (мозаики, плитки) — одним bitmap на `Dispatchers.Default` с `LruCache` (`TileArt`: `context.imageLoader.execute(... allowHardware(false))`).
- **Большие фоны — только на части экрана** с краями, плавно уходящими в цвет фона. Плеера это не касается: видео на своей плоскости.
- **Затемнения — полосами сплошного цвета** с целыми границами (`Modifier.verticalShade`), а не `Brush.verticalGradient`.
- **Анимации — только в фазе отрисовки/слоя:** масштаб и прозрачность, 150–180 мс; значение анимации не читать при композиции; раскладку не анимировать (ширина рейла переключается разом, анимируется только отрисовка).
- **Бесконечные анимации — ступенчатые** (~8 кадров/с) или никакие. Каретка поля ввода мигает шагами (2 перерисовки в секунду).
- **Где за интерфейсом видео — фокус без анимации** (`FocusStyle(animate = false)`).
- **Содержимое большой области менять с паузой после фокуса** (0,3 с: разделы настроек, библиотеки в медиатеке); «→» или OK — сразу.
- **Списки — только ленивые** с `key` и `contentType`; **короткие экраны — обычная колонка**, а не `LazyColumn` (сборка ряда при прокрутке давала кадры до 50 мс).
- **Картинки:** у сервера просить ровно размер на экране (`ImageUrls`, постер 220×330 dp → ~440×660 px), кэш в памяти ≤ ~1/6 кучи, кэш на диске, RGB_565 там, где нет прозрачности, BlurHash-заглушка.
- **Сеть:** данные экрана — параллельно, первым видимое; кэш ответов на сессию; постраничная загрузка больших библиотек.
### 3.3 Как мерить
- **Только release-сборка** (R8 + baseline profile). Compose в debug в разы медленнее, по нему судить нельзя.
- `adb shell dumpsys gfxinfo <пакет> framestats` — пропущенные кадры, перцентили и фазы (скрипт `tools/tv/framestats.py`); `dumpsys gfxinfo <пакет> reset` перед замером. Главная цифра на этом ТВ — **GPU p50/p90**: медиана кадра включает задержку конвейера (~19 мс при GPU 10 мс).
- `am start -W` — холодный запуск. Цели: прокрутка ряда без пропусков на 60 Гц, холодный запуск до первого экрана ≤ 1,5 с.
- `source tools/tv/env.sh; install_release; ui_bench right` (или `ui_bench down 4`): N нажатий вперёд и назад с паузой 0,35 с → перцентили и фазы.
- Время в логах: `Home shown in`, `Home fetched in`, `Home snapshot read in`, `Series loaded in`.
- **Состояние ТВ «плавает»:** тот же экран между прогонами даёт то ~8, то ~17 мс GPU, а иногда весь ТВ рисует втрое медленнее (главная ~32 мс вместо ~7–10) до перезагрузки. Контроль — главная ~7 мс; если хуже, `adb reboot`, а не оптимизация. Варианты сравнивать только вперемешку, по 2–3 прогона каждого.
- **Флаги-эксперименты в release без пересборки:** временно читать системное свойство (например, `debug.<app>.x`) через `SystemProperties` (reflection) и менять `adb shell setprop`. Флаги — заглавные однобуквенные без пересечений («none» содержит «e» и «o»).
- **Baseline profile:** модуль `:baselineprofile`, профиль в `app/src/release/generated/baselineProfiles/`, снять заново — `gen_profile` (ТВ с выполненным входом; от ~8 мин для главной до ~27–30 мин для полного сценария: главная → «Продолжить» → ряды → страница фильма → медиатека → поиск → избранное → настройки → страница сериала). `install_release` сразу компилирует приложение по профилю (`INSTALL_PROFILE` broadcast + `cmd package compile -f -m speed-profile`), иначе ТВ сделает это только при фоновом обслуживании.
- Gradle завершает генерацию с «There were failing tests» / «BUILD FAILED», хотя тест прошёл: ошибка на шаге сбора покрытия (причину Gradle не показывает и с `--info`). `gen_profile` копирует профиль сам.
- Генератор жмёт OK «вслепую» и может запустить воспроизведение — запускать только под тестовым аккаунтом. В первом, нескомпилированном проходе приложение отстаёт от нажатий, проверять фокус после каждого нажатия нельзя — очередь клавиш проскакивает цель. Число шагов рейла до «Настроек» генератор считает по подписям меню между «Избранным» и «Настройками» (библиотек на сервере может быть сколько угодно).
- Запуск фильма в сценарии меняет «Продолжить просмотр» аккаунта — поэтому плеер в профиль не входит.
### 3.4 Итоговые цифры (release, эталонный ТВ)
| Что | Значение |
|---|---|
| Главная, листание ряда: GPU на кадр | 45 → ~10 мс (после правил §3.2), «рывков» 8 % → 0–0,5 % (с baseline profile, первый проход тоже без рывков) |
| Главная, `ui_bench right`: кадр p50/p90, GPU p50/p90 | 15–19 / 31–32 мс, 6 / 14 мс |
| Холодный запуск (`am start -W`) | 0,35–0,6 с → 0,28–0,42 с → 243–283 мс (среднее 263) |
| Главная на экране из снимка / свежие данные с сервера | 0,09–0,16 с / 0,4–1,0 с (было 1,3–2,5 с с «Загрузка…») |
| Чтение снимка главной (130 КБ JSON) | 150 мс без профиля, 60–80 мс с профилем |
| Страница фильма: первый экран / ряд «Похожие» | GPU ~8 мс (p90 ~9) / ~7 мс |
| Плеер с OSD | GPU ~7–8 мс (было 15–20); вывод через GPU: 6 мс с OSD, 4–5 без |
| Страница сериала, лента серий | GPU ~6 мс (16 серий), ~7 (365 серий в одном сезоне), ~8,5 (28 сезонов, ~5 % рывков — столько же и без карты сезонов) |
| Загрузка страницы сериала (холодно) | 0,23 с (16 серий), 0,65–0,7 с (333–365 серий) |
| Медиатека: загрузка библиотеки / листание сетки | 0,15–0,23 с (было 0,4–0,65) / GPU ~6–7 мс, рывков ~1 % |
| Смена библиотеки | один кадр 55–70 мс (собираются ряды и первые ряды сетки) |
| Поиск: клавиатура / результаты | GPU ~8 мс / ~8 мс |
| Настройки: разделы и чипы | GPU ~4 мс |
| Экран входа с пульсирующей точкой | 47 кадров за 5 с, GPU 6 мс (при плавном пульсе — 60 к/с по ~17 мс) |
| APK release / debug | ~52,8 МБ / 106 МБ (armeabi-v7a + arm64-v8a) |
---
## 4. Фокус и пульт: общие приёмы и ловушки Compose
- **Запоминание фокуса:** `ScreenFocus` + `Modifier.remembered(focus, key)` у каждого фокусируемого элемента. После «Назад» фокус встаёт на ту же карточку, прокрутка сохраняется. `Restore` ждёт 2 кадра. `ScreenFocus.ForgetMissing(keys)` — если запомненный элемент пропал из списка (например, снято избранное), фокус встаёт на элемент по умолчанию.
- **`lastKey = x; requestFocus()` сразу не работает:** requester ещё висит на старом элементе — дождаться перекомпозиции (2 кадра). Ручная навигация между зонами экрана (`moveFocus(key, list, index)` на странице сериала): прокрутить, сменить `lastKey`, два кадра, `requestFocus`.
- **Когда пропадает слой с фокусом (панель), система ставит фокус на первую кнопку экрана** и тем перезаписывает `lastKey`. Возвращать фокус на карточку **до** исчезновения панели, через отдельный `FocusRequester` у каждой карточки.
- **Ряд чипов «фокус при входе — на выбранный»:** `Modifier.focusRestorer(requester)` + `focusRequester` на выбранном. У обычного `Row` без `focusGroup()` восстановление не срабатывает (`focusRestorer(chosen).focusGroup()`).
- **«Назад», которое никто не перехватил, сначала только снимает фокус**, и `BackHandler` срабатывает со второго нажатия. В плеере и панелях «Назад» ловится в `onPreviewKeyEvent` и выполняется **по отпусканию** — иначе отпускание прилетит следующему экрану.
- **Удержанная OK шлёт повторы** (`KeyDown` с `repeatCount > 0`), а `clickable` в Compose считает нажатием любой `KeyDown`: открывшаяся по удержанию панель нажала бы свою первую кнопку сама. Панель принимает OK только после «свежего» нажатия (`repeatCount == 0`).
- **`Key.Menu`** Compose на Android — тот же `KEYCODE_MENU`, отдельного `nativeKeyCode` не нужно.
- **Ввод с физической клавиатуры** на экране без поля — `onPreviewKeyEvent` с `nativeKeyEvent.unicodeChar`.
- **Вертикальная прокрутка экранов с рядами — своя** (`AnchoredScrolling` + `anchorViewport` на список, `anchoredRow` на элемент): 450 мс с замедлением к концу. Встроенную анимацию bring-into-view в текущем Compose настроить нельзя, и она «выстреливала». `AnchoredScrolling` generic по `ScrollableState` (`animateScrollBy`) — годится и для колонки, и для `LazyColumn`/`LazyVerticalGrid`.
- **Встроенная TV-прокрутка** держит фокус около 30 % от края: горизонтальные ряды так и оставлены; панели, которые не должны прокручиваться при первом же фокусе, используют `MinimalScrolling(margin)` (прокрутка ровно на сколько нужно).
- **Экраны-сетки** (медиатека, избранное, поиск, человек): весь экран — одна `LazyVerticalGrid`; ряды и заголовки — элементы `span = maxLineSpan`, растянутые на всю ширину экрана модификатором `bleed(fullWidth)` поверх боковых полей сетки; ячейки `GridCells.FixedSize(POSTER_W)`; подгрузка страниц по `snapshotFlow` от `layoutInfo` (`ui/components/Grid.kt`: `fullItem`, `bleed`).
- **Нажатие в первом кадре после появления OSD уходит в никуда.** С пульта так не бывает, но в скриптах между нажатиями нужны паузы ≥ 0,25 с.
---
## 5. По областям
### 5.1 Основа: дизайн-система, компоненты, оболочка, сборка
- `ui/theme` (`Tokens`): цвета, типографика, отступы, радиусы, свечение фокуса из макета. `ui/components`: `FocusCard`/`focusCard` (фокус-узел со свечением), `GlowCache`, `Cards` (постер, широкая, большая), `Buttons` (основная с градиентом, круглые, чип; `Chip(swatch = …)` — цветная точка), `Icons`, `RemoteImage` + `CardImageTransformation`, `BlurHash`, `Avatar`, `StatusDot`, `Brand`, `Grid`, `Section`, `Scrolling`, `TextInput`.
- **Оболочка** (`ui/shell/Shell.kt`): «Назад» на вкладке → фокус в рейл, «Назад» в рейле → выход из приложения.
- **Упрощения макета ради скорости:** нет сетки точек на фоне; у постеров нет затемнения снизу — значок «4K» на маленькой тёмной плашке; полоска прогресса на постере — со скруглёнными концами и отступами, а не во всю ширину по краю; тень раскрытого рейла — узкая полоса 40 dp вместо размытия 80 px. Длинное название в большой карточке обрезается многоточием.
- **Release-сборка:** R8 с keep-правилами (ass-media лезет рефлексией в поля `MatroskaExtractor` — `extractorOutput`, `subtitleSample`; libVLC; ffmpeg; SDK), `profileinstaller`, подпись пока debug-ключом. Воспроизведение Exo и VLC в release проверено.
- **Открытие из adb** (`am start -n ru.stenfredd.sleepyowl/.MainActivity …`), работает и в release: `--es item <id> [--ez play true] [--el start <мс>]`; `--es series <id> [--es episode <id>]` — страница сериала. Только в debug: `--es engine exo|vlc`, `--ez tour true [--ei tour_file N]` (режим обхода). Без `--el start` фильм с `--ez play true` начинается с нуля и выпадает из «Продолжить просмотр».
### 5.2 Данные
- **Репозитории `:core:data`:**
- `LibraryRepository` — библиотеки, новинки (`/Items/Latest`, в т. ч. по библиотеке), страницы элементов с сортировкой и фильтрами (`items(query, start, limit)`), количество, подробности, сезоны, серии, похожие, поиск (`search(term, view)`, `searchPeople`), человек и фильмография, сегменты;
- `WatchRepository` — Resume, NextUp, «Продолжить просмотр», история, избранное, «просмотрено»/«в избранное» — всё и по библиотеке (`parentId`);
- `HomeRepository` — содержимое главной; `UserSettingsRepository` — языки на сервере, имя и версия сервера.
- **`SessionCache`** — кэш ответов в памяти: одинаковые запросы в полёте объединяются; всё, что зависит от просмотра (`Kind.UserData`), сбрасывается по событию; ключи по аккаунту; `dropUserData()` — сбросить зависящее от пользователя и сказать экранам перезагрузиться; `invalidations` — поток событий для экранов.
- **Обновление после просмотра:** `PlaybackSession` после `reportPlaybackStopped` вызывает `JellyfinSession.notifyUserDataChanged` → кэш сбрасывает данные просмотра → экраны перезапрашивают в фоне. То же делают `setPlayed`/`setFavorite` и меню «просмотрено».
- **Главная показывается сразу из снимка** (последний показ в этом запуске или JSON-файл с прошлого запуска, `files/home/`), затем обновляется с сервера одним параллельным заходом.
- **Ряды библиотек на главной — через `/Items/Latest?parentId`** (20–80 мс на сервере; «недавно пополненное»: сериал с новой серией поднимается вверх) вместо `/Items` по дате с подсчётом (≈0,2 с каждый, а 5 параллельных сервер выполнял ≈1 с). Число элементов библиотеки догружается отдельно (появляется чуть позже ряда) и помнится 30 мин.
- **«Продолжить просмотр»** = начатое (сначала самое недавнее), потом следующие серии (NextUp). **Один тайтл — одна карточка:** от сериала остаётся последняя по времени просмотра начатая серия (resume у сервера отсортирован по дате просмотра, `distinctBy { seriesId ?: id }`); «следующая серия» — только у сериалов без начатых. Начатых запрашивается втрое больше лимита, чтобы после схлопывания ряд оставался полным. Тот же вызов используют главная, медиатека и ряд оболочки ТВ.
- **Локальный адрес для всего трафика:** один `OkHttpClient` (`core/jellyfin/HttpModule.kt`) для SDK и Coil с перехватчиком `ServerRouting`: запросы на публичный адрес уходят на LAN-адрес сервера, если он известен. Адреса картинок строятся на публичном адресе — это ключ кэша, он одинаков дома и вне дома. Если LAN-адрес не отвечает (вне дома), запрос за 1,5 с уходит на публичный адрес и запускается новый поиск сервера. Найденный LAN-адрес запоминается в аккаунте и используется сразу при следующем запуске. Видео и субтитры тоже идут на LAN-адрес (`authorizedUrl`). Причина: hairpin через роутер и TLS на процессоре ТВ давали Exo 40–115 Мбит/с против ~240 Мбит/с напрямую.
- **Несколько аккаунтов** (`core/jellyfin/Accounts.kt`, `JellyfinSession`): сервер + пользователь + токен + LAN-адрес, список в DataStore (JSON), текущий аккаунт; `signedIn`, `switchTo(id)`, `logout()` (отзывает токен), `forget(accountId)`, `accounts`, `restored` (до него `session == null` не значит «вышли»).
- **Картинки:** Coil 3 + Image API Jellyfin ровно под размер на экране (`fillWidth`/`fillHeight`/`quality`). **BlurHash** приходит с каждым элементом: декодируется 16 px, увеличивается до ¼ карточки с запечёнными углами и затемнением под текст, рисуется одним bitmap; декодирование вне главного потока. Картинки и API ходят через тот же LAN-маршрут.
- **Значок качества («4K», «1080p») на постерах требует `fields=Width`** — без него сервер не отдаёт ширину видео.
- Карточка без картинки на сервере — пустая плашка.
### 5.3 Вход
- **Экран входа** (`ui/login`: `LoginScreen`, `LoginViewModel`, `QrCode`): шаги «Сервер → Профиль → Вход» с цепочкой шагов вверху; «Назад» — на предыдущий шаг, с первого — выход из экрана.
- **Сервер:** карточки серверов из LAN (UDP 7359, все ответившие; имя, LAN-адрес, версия, пинг по уже открытому соединению; не больше трёх карточек) + серверы сохранённых аккаунтов, которых в сети нет (пометка «ИНТЕРНЕТ», пинг через сохранённый адрес) + «Ввести адрес». Поиск повторяется каждые 8 с, пока открыт шаг. Введённый адрес проверяется через SDK `getRecommendedServers` — он сам подставляет схему и порт (`jellyfin.example.com`, голый IP и т. п.). Для найденного в сети сервера аккаунт сохраняется с его публичным адресом (если сервер его объявляет и отвечает по нему тем же id) и LAN-адресом.
- **Поиск сервера в LAN** (`LocalServerDiscovery`): UDP-запрос «Who is JellyfinServer?» на порт 7359, берётся IP отправителя + порт 8096, проверка `/System/Info/Public` → тот же `Id`. Поле `LocalAddress` в ответе сервера за reverse proxy содержит публичное имя, поэтому на него полагаться нельзя.
- **Профиль («Кто смотрит?»):** сохранённые на ТВ аккаунты этого сервера («сейчас», «сегодня», «вчера» …) + публичные пользователи (`/Users/Public`; пуст, если пользователи на сервере скрыты) + «Другой». Если выбирать не из чего, шаг пропускается (в цепочке он всё равно отмечается пройденным). Пользователь без пароля входит сразу. Аватар — градиентный круг с буквой, цвет по id пользователя (формула из макета); он же в рейле.
- **Вход:** Quick Connect (код крупно + QR со ссылкой `<сервер>/web/#/quickconnect?code=…` — веб-клиент Jellyfin 12 подставляет код из ссылки; опрос раз в 3 с; код обновляется, когда сервер его забывает; если Quick Connect выключен на сервере — так и написано) или имя и пароль. Ошибки по-русски («Неверное имя пользователя или пароль», «Нет связи с сервером» …). QR — ZXing core 3.5.3.
- **Слой входа** (`ServerLogin`) работает через отдельный клиент без токена с тем же device id; `JellyfinSession.signedIn` сохраняет аккаунт. Ловушка прежней реализации: вход через основной клиент сбрасывал LAN-маршрут текущего аккаунта ещё до успешного входа.
- **Смена профиля:** пункт аккаунта в рейле открывает «Кто смотрит?» текущего сервера; «Назад» — к серверам (как в макете), ещё раз — обратно в приложение. При запуске открывается последний аккаунт без выбора.
- **Поле ввода для пульта** (`TextInput` + `rememberTextInputState()`): фокус встаёт на рамку, OK — курсор и клавиатура; кнопка действия клавиатуры, «Назад», ↑/↓ — выход из поля (`BasicTextField` иначе захватывает D-pad).
- **Gboard для ТВ плавает поверх нижней половины экрана (~45 %) и не сообщает свою высоту** (IME insets нулевые), поэтому `adjustPan`/`adjustResize` не работают. Экран сам приподнимается, пока поле редактируется (`KeyboardLift` + `Modifier.liftForKeyboard`, сдвиг слоя; содержимое обёрнуто в `CompositionLocalProvider(LocalKeyboardLift provides lift)`), у активности `adjustNothing`. Для клавиатур, которые сообщают высоту, берётся она.
- **Индикатор «Ищем…» / «Ждём подтверждения»** — `StatusDot` со ступенчатым пульсом (~8 кадров/с), см. §3.1.
- Упрощение: фон без радиального свечения.
### 5.4 Главная и страница фильма
**Главная** (`ui/home`): «Продолжить просмотр» (большая карточка первого + 4 широких), «Недавно добавлено», ряд на каждую библиотеку. Данные — §5.2.
**Страница фильма** (`ui/details`: `DetailsScreen`, `DetailsLayout`, `MediaLabels`, `BackdropTransformation`; `Screen.Detail`). Во весь экран, без рейла.
- Состав: постер, значки (4K/1080p, HDR10/HDR10+/DV/HLG, Atmos/7.1/5.1), строка файла («HEVC 2160p DV P8 · 39.4 ГБ»), метка «ФИЛЬМ», название (до 2 строк), ★ / возраст / год / длительность / жанры, описание (до 4 строк), «Продолжить · ещё N мин» / «Смотреть», «С начала» (если начат), ♥, ✓, «Параметры воспроизведения» (аудио и субтитры), «В ролях · режиссёр …» (фото с сервера, иначе инициалы; открывают страницу человека), «Похожие». Для серии (открывается только из intent) — метка «СЕРИЯ · S1 · E3», название сериала и серии, постер сериала, без «Похожих».
- **Подписи дорожек:** язык + собственное название дорожки из файла («Русский · Дубляж», «Русский · <студия>») — у локализованных релизов именно оно отличает дорожки одного языка; справа кодек и каналы («AC3 5.1», «E-AC3 Atmos 5.1»). Субтитры: «Выкл» + дорожки («SRT», «PGS», «ASS»; forced без названия → «надписи»). `defaultSubtitleIndex` = null, если сервер дал -1.
- **Ряды чипов дорожек прокручиваются в строку** (бывает десяток субтитров), ряд открывается на выбранной дорожке, вход в ряд сверху/снизу — на выбранную (`OptionRow`). Выбор передаётся в плеер (`Screen.Player.streams`) и запоминается для фильма (§5.5).
- **Ловушка навигации:** «вверх» из ряда субтитров ведёт на *выбранный* чип аудио, а оттуда «вверх» — на ближайшую по горизонтали кнопку (часто ♥), а не на «Продолжить». В скриптах к «Продолжить» идти явно (`DPAD_LEFT` до упора).
- **Избранное и «просмотрено»** меняются на экране сразу, запрос на сервер, при ошибке откатываются. Если кнопка исчезла («С начала» после досмотра), `ScreenFocus.lastKey` переводится на «Продолжить».
- **Возраст:** префикс системы рейтинга убирается («RU-16+», «age6+» → «16+», «6+»), «PG-13» остаётся.
- Кнопки 42 dp, чип 29 dp со светлой заливкой в фокусе.
- **Фон:** картинка на весь экран + затемнение даже запечённая в один непрозрачный bitmap стоила ~12 мс GPU (кадр 19 мс вместо 7). Поэтому фон — **только справа сверху, 70 % ширины и высоты** (672×378 dp), края плавно уходят в фон, затемнение макета запечено (`BackdropTransformation`). При прокрутке к актёрам фон уходит вверх вместе с первым экраном. Свечение вокруг постера убрано. Без фона на сервере — тёмный фон.
### 5.5 Плеер
Код: `ui/player` — `PlayerScreen` (состояние OSD `PlayerUiState`, клавиши `onKey`/`onNavigationKey`), `PlayerViewModel` (в `ui:shared`: сессия, `PlayerInfo`, `SeasonEpisodes`, `playEpisode(id)`, качество, вид субтитров, сегменты), `PlayerOsd`, `PlayerPanels` (`SidePanel`, `PanelList`, `PanelChips`), `PlayerDrawer`, `PlayerNext`, `PlayerSkip`, `PlayerLabels`, `PlayerDebug` (только debug).
- **OSD:** метка «ФИЛЬМ»/«СЕРИАЛ», название, «год · жанры» или «Сезон 1 · Серия 4 · название», часы, «закончится в …»; время, таймлайн (буфер — только у Exo, libVLC его не сообщает; отметки глав; части заставок/титров), ⏮ ⟲ ⏯ ⟳ ⏭ (⏮/⏭ — у серий), чипы «Аудио», «Субтитры», «Качество», «Direct/Транскод», подсказка «⌄ О фильме».
- **Пульт:** при скрытом OSD ←/→ — перемотка по 10 с (при удержании 30, потом 60 с) с показом таймлайна; нажатия копятся и уходят в движок одним seek через 0,6 с; OK — пауза; ↓ — шторка; ↑ — OSD. Медиаклавиши (⏯, ⏩/⏪, ⏭/⏮ серии) работают всегда. OSD прячется через 6 с без нажатий (только во время игры; не на паузе и не при открытой панели/шторке; в начале воспроизведения он виден, пока идёт загрузка, + 6 с). «Назад»: панель → шторка → OSD → выход.
- **Уход с экрана (Home):** `PlaybackSession.pauseInBackground()` на `ON_STOP` — пауза, отчёт о позиции на сервер и `notifyUserDataChanged`. Раньше была только пауза, и списки (в том числе ряд оболочки ТВ) не узнавали о новой позиции.
- **Панели справа** (непрозрачные; фокус из панели не уходит на кнопки за ней):
- Аудио (выбор закрывает панель);
- Субтитры: дорожка + размер S/M/L/XL, цвет белый/жёлтый/голубой, фон нет/тень/плашка, превью; пояснение для ASS и графических;
- Качество: «Оригинал» + потолки 20/10/4/1,5 Мбит/с ниже битрейта файла (`QualityCaps.ALL`); сервер сам решает — прямое воспроизведение, если файл влезает, иначе транскод. «Авто» нет (нужен замер скорости сети);
- Информация о потоке: режим и причина транскода по-русски, контейнер, видео, частота, битрейт, аудио, субтитры, движок, декодер, звук, буфер, пропуски + **«Дополнительно»**: движок (Авто/ExoPlayer/VLC), вывод видео (Авто/видеослой/GPU), Dolby Vision (базовый слой/DV-декодер — только для DV-файлов). «Авто» возвращает к значению из настроек.
- **Вид текстовых субтитров** (`SubtitleStyle` в `playback/api`, интерфейс `SubtitleStyleable`): Exo — `SubtitleView` на лету; пока виден OSD, субтитры Exo приподнимаются над ним (`setSubtitleLift`). VLC — параметрами freetype при создании libVLC (libVLC создаётся лениво), поэтому смена вида при включённых субтитрах перезапускает VLC с той же позиции. ASS и PGS рисуются как есть. Вид общий для ТВ (DataStore `playback`).
- **Запоминание дорожек** (`PlaybackPreferences`): на аккаунт и фильм/сериал. Дорожка ищется в следующем файле по языку, собственному названию и кодеку (`TrackPick.bestOf`): номера потоков у серий разные. Запоминается выбор в плеере, на странице фильма и в панели сериала; плеер без явного выбора (следующая серия, запуск извне) берёт запомненное.
- **Качество:** выбранное в плеере переходит на следующую серию; иначе берётся качество по умолчанию из настроек (`PlaybackPreferences.defaults`, применяет `PlaybackSession` при старте).
- **Шторка (↓):** «О фильме» (постер, ★, возраст, год, длительность, жанры, описание, режиссёр, 4 актёра с фото) с кнопкой «Страница фильма»; у серий — «Серии · сезон N» (лента сезона, текущая «СЕЙЧАС», ✓, «НОВАЯ»; выбор серии играет её с места остановки без выхода из плеера, шторка при этом сворачивается) и кнопка «К сериалу» (возврат на страницу сериала с фокусом на играющей серии; ↑ из ленты ведёт на неё).
- **Конец:** у серии — «Следующая» (§5.6, §5.12), конец фильма — выход из плеера.
- **Скорость:** видео на своей плоскости, окно интерфейса рисуется только при изменениях: OSD скрыт — 0 кадров; виден — кадр раз в секунду (часы) и по одному на нажатие. Затемнения сверху и снизу — полосы сплошного цвета по 4 dp с целыми границами, высота 105 + 170 dp (в макете 130 + 230 dp градиентом, без радиальной виньетки). Фокус без анимации: анимированная смена фокуса — ~8 дорогих кадров на нажатие, мгновенная — один (на нажатие ~4 кадра вместо ~15). У полосы прогресса, ползунка и значка паузы нет свечения. Под панелями нет затемнения экрана.
- **Debug-сборка:** значок варианта, цифры 0–9 (варианты движка/вывода), MENU — старое отладочное меню, INFO — диагностика, режим обхода.
- **Скрипты:** снимок экрана на ТВ занимает ~5 с (OSD за это время может спрятаться); HDR/DV на видеослое на `screencap` не виден, SDR — виден.
### 5.6 Сериалы
Код: `ui/series` — `SeriesScreen` (раскладка без прокрутки, ручная навигация ↑/↓ между кнопками, чипами и лентой), `SeriesViewModel` (модель серий/сезонов, «следующая», дорожки), `SeriesTray` (нижняя панель). `Screen.Series(seriesId, episodeId?)`; время загрузки в логе — `Series loaded in`.
- **Экран** во весь экран без рейла и без вертикальной прокрутки: постер, «СЕРИАЛ · 2 СЕЗОНА · 16 СЕРИЙ», название, ★ / возраст / год / жанры / значки файла следующей серии; «Продолжить S1 E4 · ещё 38 мин» или «Смотреть S1 E5 · 46 мин»; кнопка «● AC3 5.1 ● Русский» (нижняя панель дорожек); ♥; ✓ (меню «просмотрено» для всего сериала, §5.11); карта сезонов; чипы сезонов («Сезон 1 3/10», «✓», «новый»); все серии всех сезонов одной лентой с разделителем «СЕЗОН N»; внизу — описание выбранной серии (2 строки), длительность и дата выхода.
- **Карточки сериала, сезона и серии ведут сюда** (на серию — фокус и прокрутка на ней).
- **Какая серия «следующая»:** последняя включённая, если не досмотрена (даже если её включили на секунды и сервер не сохранил позицию), иначе первая непросмотренная после неё; ничего не смотрели — первая непросмотренная. Спецвыпуски (сезон 0) — в конце, в «следующую» не попадают.
- **Состояния серий:** ✓ просмотрена, «42%» начата (+ полоска), «далее», «новая» (14 дней). На карте: зелёная / фиолетовая / розовая рамка / серая рамка, выбранная — высокая белая. Полоски сужаются для длинных сезонов; если всё равно не влезает (28 сезонов), показывается окно сезонов вокруг выбранного с «…».
- **Карта сезонов запекается в bitmap один раз** на сезон; выбранная серия — отдельный белый маркер, который двигается слоем (`graphicsLayer.translationX`) без перерисовки. Сотни рамок по 1 px на кадре стоили ~9 мс GPU (17 мс вместо 8 на сериале с 28 сезонами).
- **Пульт:** ↓ с кнопок → чип текущего сезона → его следующая серия; ↑ из ленты → чип сезона этой серии; ↑ с чипов → «Продолжить». Движение по чипам прокручивает ленту к сезону, движение по ленте переключает чип и карту. OK на чипе — к серии сезона; OK на серии — плеер (с места остановки; просмотренная — с начала).
- **Нижняя панель «Аудио и субтитры»:** три колонки — аудио, субтитры, вид субтитров (общий `SubtitleChoices` с плеером). Дорожки — из файла следующей серии, выбор сразу запоминается для всего сериала, плеер ищет похожую дорожку в каждой серии. Фокус не уходит из панели; «Назад» (по отпусканию) или ↓ ниже края — закрыть, фокус возвращается на кнопку дорожек. Панель непрозрачная, без затемнения экрана.
- **Плеер:** карточка **«Следующая через N с»** за 30 с до конца серии (если следующая есть; при известных титрах — с их началом, §5.12): кадр, «S1 · E6 · название», «Смотреть сейчас» / «Досмотреть титры», полоска отсчёта. Над голым видео карточка берёт фокус (←/→/OK — её кнопки, ↑ — OSD, ↓ — шторка); при показанном OSD стоит над ним. «Назад» при скрытом OSD = «Досмотреть титры». В конце серии следующая включается сама. `SeasonEpisodes.nextEpisode`, `PlayerInfo.seriesId`.
- **Данные сервера:** все серии сериала (365 серий) сервер отдаёт за ~0,1 с (~1 МБ JSON), остальное время — разбор. У некоторых сезонов сервер не знает номеров серий — номер берётся по порядку в сезоне; у серий без своего кадра — картинка сериала.
- Упрощения: фон как у фильма (справа сверху), без свечения у выбранного чипа, полосок карты и прогресса; кнопки шапки 42 dp.
### 5.7 Медиатека
Код: `ui/library` — `LibraryScreen`, `LibraryViewModel`, `TileArt`, `LibraryMemory`.
- **Плитки библиотек:** «● БИБЛИОТЕКА», название, «158 фильмов · 3 новинки» (новинки — `/Items/Latest` библиотеки за 14 дней; нет новинок — только число; у смешанной библиотеки — «N тайтлов» и заголовок «Всё»), мозаика из трёх свежих постеров. Фон плитки (заливка, наклонённые постеры, затемнение слева, скругления) запекается в один bitmap в двух видах (выбранная ярче). Плитки фиксированной ширины 260 dp (видно три), ряд прокручивается. Фокус на плитке выбирает библиотеку через 0,3 с (быстрое листание не грузит промежуточные); OK — вниз, в её содержимое. Библиотеку можно открыть и из рейла.
- **Под плитками:** «Продолжить просмотр» (по библиотеке), «Недавно добавлено», «История просмотра» (серии сведены к сериалу, подпись «вчера», «3 дня назад» — `Words.daysAgo`), «Все фильмы/сериалы N» с чипами А–Я / Добавлено / Год / Рейтинг и «Непросмотренное», сетка постеров по 6 в ряд (у сериала «3 сезона · ★ 8,2»).
- **Весь экран — одна ленивая сетка** (§4). Сетка грузится страницами по 60 (следующая — когда до конца остаётся 2 ряда), без `TotalRecordCount`; число — отдельным запросом. Смена сортировки оставляет старые карточки до прихода новых (иначе экран «прыгает»). Сетка прокручивается, когда фокус уходит на не полностью видимый ряд.
- **Память:** выбранная библиотека и сортировка каждой библиотеки помнятся до перезапуска (`LibraryMemory`, singleton); содержимое уже открытых библиотек показывается сразу и обновляется в фоне; после просмотра перезагружается.
- **Сервер: запрос «фильмы и сериалы» в библиотеке в ~10 раз медленнее одного типа** (список 60 шт.: 0,35–0,44 с против 0,05; подсчёт: 0,2 с против 0,02). Списки и подсчёты просят только тип библиотеки (`LibraryView.itemKinds`) — это ускорило и числа в заголовках рядов главной.
- **Смена библиотеки** даёт один кадр 55–70 мс; baseline profile почти не помогает. Он приходится на момент, когда ничего не движется, и на глаз выглядит как появление содержимого. Если станет заметно — собирать ряды по одному в соседних кадрах.
- Коллекции и папки (BoxSet/Folder) открывают простую сетку `ItemsScreen`.
### 5.8 Поиск, избранное, страница человека
- **Поиск** (`ui/search`): слева поле запроса и экранная клавиатура 6×7 — русские буквы (+ «-», «.», «,»), «ABC·123» переключает на латиницу и цифры, «⌫» (долгое нажатие — стереть всё), «Пробел». Ввод с физической клавиатуры и цифр с пульта работает на всём экране. Справа без запроса — «Недавние запросы» и «Недавно добавлено», с запросом — «Найдено: N», группы по библиотекам (в порядке библиотек) + «Люди» последними (круглое фото или инициалы). Поиск идёт через 0,35 с после последнего нажатия. Постеры в результатах — обычные (110 dp, название под картинкой, 4 в ряд).
- **Недавние запросы** (`core/data/SearchHistory`, DataStore, по аккаунту, до 8): запрос запоминается, когда открыт один из его результатов.
- **Сервер: `/Persons?searchTerm=` не находит людей по-кириллице** на Jellyfin 12 (русский запрос → 0 результатов); `/Items?includeItemTypes=Person&searchTerm=` находит. Поиск людей — через `/Items`.
- Скорость сервера: поиск по библиотеке своим типом — 0,03–0,07 с; избранное по библиотекам — ~0,05 с; фильмография — 3 запроса (фильмы, сериалы, серии) ~0,1 с.
- **Избранное** (`ui/favorites`): «Избранное N тайтлов», чипы «Всё» + библиотеки, где есть избранное (с числом), сетка постеров с ♥ и подписью «Фильм · 2021» (`CardMapper.kindPoster`, `PosterModel.favorite`). Если отмеченная карточка пропала — фокус на «Всё».
- **Страница человека** (`ui/person`, `Screen.Person`; в макете нет): фото, имя, «5 фильмов · 1 сериал», сетка фильмов и сериалов (новые сначала); гостевая роль в серии показывается сериалом. Открывается из «Людей» в поиске и с актёров на странице фильма.
- `adb shell input text "…"` печатает только латиницу; кириллицу — экранными клавишами; стирать `KEYCODE_DEL`.
### 5.9 Настройки
Код: `ui/settings` — `SettingsScreen`, `SettingsViewModel`, `Licenses`, `LicenseScreen`; вкладка `Tab.Settings` внизу рейла над профилем. Раскладка «раздел слева — настройки справа» (`Setting`, `Choices` — ряд чипов с фокусом при входе на выбранном). Выбор применяется сразу. Раздел показывается через 0,3 с после фокуса (§3.1).
- **Воспроизведение** (на ТВ, для всех профилей; `PlaybackPreferences.defaults` / `PlaybackDefaults`): качество по умолчанию (Оригинал / 1080p 20 / 1080p 10 / 720p 4 / 480p 1,5 Мбит/с), вывод видео (Авто / Видеослой ТВ / Через GPU — для Exo), Dolby Vision (базовый слой / DV-декодер — пункт есть, только если у ТВ есть DV-декодер и экран с DV), «Заставки» и «Титры» (§5.12).
- **Языки** — настройки пользователя на сервере (`AudioLanguagePreference` + `PlayDefaultAudioTrack`, `SubtitleMode`, `SubtitleLanguagePreference`): язык звука (Как в файле / Русский / Английский / Японский + текущий, если другой), субтитры (Как в файле / Выкл / Только надписи / Если звук не на моём языке / Всегда), язык субтитров (Любой / Русские / Английские). Сервер сам применяет их при выборе дорожек по умолчанию; после смены кэш сбрасывается. Пишутся только изменённые поля.
- **Сервер помнит дорожки, выбранные при прошлом просмотре** (`UserData.AudioStreamIndex/SubtitleStreamIndex`, «Запоминать выбор» в профиле Jellyfin) — для таких элементов языки из настроек не действуют. На экране сказано, что выбранное вручную важнее.
- **Вид субтитров** — как в панели плеера.
- **Аккаунт и сервер:** аватар, имя, «Сервер «<имя>» · Jellyfin 12.x», адрес, «Дома: напрямую, <LAN-адрес>»; «Сменить профиль», «Выйти» (со второго нажатия «Точно выйти?»); другие сохранённые профили — «Переключиться» и «Забыть» (тоже со второго нажатия). «Выйти» и «Забыть» отзывают токен (`POST /Sessions/Logout`, `sessionApi.reportSessionEnded`, не дольше 3 с; без сети — просто забывают).
- **О приложении:** версия и сборка, модель ТВ; лицензии — приложение (GPL-3.0) и компоненты (libVLC LGPL-2.1, FFmpeg для Media3 GPL-3.0, Jellyfin SDK LGPL-3.0, Media3 и форк `PsExtractor` Apache 2.0, ass-media MIT, libass ISC, AndroidX/Compose/Kotlin/Koin/Coil/OkHttp/ZXing/Timber Apache 2.0, шрифты OFL 1.1). OK — полный текст из `assets/licenses` (↑/↓ листают, ←/→ — страницами).
- **Отладка** (только debug): «Режим обхода».
### 5.10 «Продолжить просмотр» на главном экране ТВ (своя оболочка)
Контракт — [`launcher-feed.md`](launcher-feed.md). Сторона приложения:
- **`LauncherFeed`** лежит в `app` (не в `core/data`: интент ссылается на `MainActivity`, телефону это не нужно). Запускается из `SleepyOwlApp.onCreate` одной корутиной на IO; ждёт `JellyfinSession.restored` и ещё 3 с, чтобы не мешать главной (её списки к тому времени уже в `SessionCache`, лишних запросов нет). Пересчёт — при старте, при смене аккаунта и по `SessionCache.invalidations`, пачки событий схлопываются за 1 с. В логе: `Launcher feed: sent N cards`.
- **`MainActivity` — `singleTask` + `onNewIntent`.** Раньше запуск с `NEW_TASK` при открытом приложении просто поднимал старую задачу: `intentTarget()` читался один раз в `LaunchedEffect(Unit)`, и открывался прежний экран. Теперь цель из нового интента → стек «главная приложения + цель», как при холодном старте; открытый плеер закрывается и сохраняет позицию. Запуск без цели (иконка) и экран входа — экран не меняется.
- Под плеером, запущенным с карточки, — страница фильма, у серии — страница сериала на этой серии (экстра `series` + `item`). Запомненные дорожки подхватываются.
- «Home shown in» после добавления ленты не изменился (140–153 мс против 137–151).
### 5.11 Отметка «просмотрено / не просмотрено»
Код: `ui/menu/WatchMenu.kt` (`WatchMenuHost` над всей навигацией в `MainActivity`, `LocalWatchMenu`, панель и тексты); в `ui:shared` — `WatchTarget`, `WatchMenuViewModel`, `Modifier.clickableWithMenu(target, onClick)` (`combinedClickable` + Menu) вместо `clickable` у карточек. Цели строят `CardMapper.watchTarget(item)` и `SeriesViewModel` (с точными числами серий).
- **Меню по удержанию OK** (системный порог долгого нажатия, ~0,4 с) или по кнопке Menu пульта. Работает на всех карточках (постеры, «Продолжить» большая и малые, «Похожие»), на сериях и чипах сезонов. Короткое нажатие — как раньше.
- **На странице сериала** — круглая ✓ рядом с ♥ (зелёная, если просмотрено всё): открывает меню для всего сериала, а не переключает — отметка целого сериала стирает места остановки и даты, поэтому сначала видно, сколько серий затронет. У фильма ✓ на странице — мгновенный переключатель.
- **Меню** — панель справа во всю высоту (`SidePanel`, непрозрачная): «ФИЛЬМ / СЕРИЯ / СЕЗОН / СЕРИАЛ», название, состояние («Просмотрен», «Просмотрено 42%», «Просмотрено 5 из 6 серий», «Не просмотрено: 24 серии») и только осмысленные действия: «Отметить просмотренным» (у сезона/сериала — «весь сезон/сериал», с числом серий), «Отметить непросмотренным» (если что-то просмотрено или начато; «Место остановки сбросится» / «Отметки и места остановки всех серий сбросятся»). Окончания по роду. После выбора — «Сохраняем…», ответ сервера → панель закрывается, фокус на карточку; ошибка — текст в панели.
- **Сервер:** сезон и сериал отмечаются одним запросом (`markPlayedItem`/`markUnplayedItem` на сезон/сериал — сервер сам проходит по сериям). Снятие отметки сбрасывает и место остановки, и `LastPlayedDate`. При любой отметке у записи пользовательских данных меняется внутренний `Key`. У карточки сериала сервер сообщает только число непросмотренных, не просмотренных, поэтому меню с карточки всегда предлагает и «непросмотренным» (на странице сериала — только если есть что снимать).
- **Ловушки:** повторы удержанной OK (§4); возврат фокуса до исчезновения панели (§4); «Назад» — по отпусканию.
- **adb:** `input keyevent --longpress KEYCODE_DPAD_CENTER` — долгое нажатие; `--duration` этот ТВ не знает и шлёт **обычное** нажатие (запустит воспроизведение).
- Меню — отдельный слой, только когда открыто; рисование карточек не изменилось.
### 5.12 Пропуск заставок и титров
Данные — стандартный Media Segments API Jellyfin (`GET /MediaSegments/{itemId}`, в SDK `mediaSegmentApi.getItemSegments`), подойдёт любой источник сегментов; проверено с плагином Intro Skipper 12.0.6. Типичные данные: `Intro` 0–88 с (бывает после пролога, например 94,5–178,9 с), `Outro` до конца файла; бывают титры в середине (за ними эпилог ~2 мин).
Код: `core/data` — `MediaSegment`/`SegmentKind`, `MediaSegmentDto.toSegment()`, `LibraryRepository.segments(id)` (кэш `Stable`); `PlayerViewModel.segments` грузится отдельно от информации о серии (ошибка сегментов не ломает шторку); `SeasonEpisodes.currentId` (пока сезон новой серии не загружен, «следующая» из старого не используется); `SkipPlan.of(...)` — что предлагать, что пропускать и где начинаются «хвостовые» титры (`Outro`/`Preview`, доходящие до конца файла с допуском 10 с); `SegmentSet` — что уже пропущено / куда перемотал пользователь / у чего кнопка спряталась (по id серии, смена серии забывает); `SkipButton`, `SkipNotice`; логика в `PlayerScreen.kt` (`skip`, `autoSkip`, `creditsGo`, `nextLeftMs`).
- **Кнопка «Пропустить заставку 1:12»** (время до конца части) — справа внизу, на месте карточки «Следующая» (пилюля 40 dp, светлая в фокусе, непрозрачная). Над голым видео сразу получает фокус: OK пропускает, ←/→ перематывают, ↑ — органы управления, ↓ — шторка, «Назад» убирает кнопку (второй «Назад» — выход). Через **10 с над голым видео** кнопка прячется (считается только время без органов управления; иначе 1,5 мин опенинга OK не ставит паузу) и возвращается вместе с ними — над таймлайном (отступ 150 dp, туда же поднята карточка «Следующая», чтобы не задевать оставшееся время), достаётся ↑ с таймлайна. После пропуска фокус — на ⏯ или на плеер. Пропущенная часть больше не предлагается в этом просмотре.
- Подписи по типу: «Пропустить заставку / пересказ / анонс / титры / рекламу» (`Intro/Recap/Preview/Outro/Commercial`); `Unknown` и куски короче 2 с не учитываются.
- **Титры до конца файла у серии со следующей:** карточка «Следующая» появляется с их началом и отсчитывает **15 с**, потом включает следующую серию. Без данных о титрах — за 30 с до конца. Титры **не до конца** — кнопка «Пропустить титры». Титры до конца **без** следующей серии (фильм, последняя серия) ничего не показывают.
- **Настройки «Заставки» и «Титры»:** «Кнопка» (по умолчанию) / «Сразу» / «Не пропускать» (`PlaybackDefaults.skipIntro/skipCredits`, `SkipMode`; на ТВ для всех профилей). «Сразу»: заставка перескакивается сама (плашка «Заставка пропущена» на 3 с), следующая серия включается с началом титров без карточки. Если пользователь сам перемотал внутрь части, она не пропускается — показывается кнопка. «Не пропускать» у титров — карточка за 30 с до конца.
- **Таймлайн:** части, которые плеер пропускает или предлагает пропустить, подсвечены фиолетовым на непройденной части.
- **Ловушки:** перемотка в часть помечается при фиксации (`SCRUB_COMMIT_MS`), до того как движок сообщит новую позицию, — иначе «Сразу» перескакивал бы туда, куда перемотали нарочно. В adb-скриптах кнопка живёт 10 с: пока разглядываешь снимок, она прячется, и следующий OK ставит паузу — нажатия и снимки слать одной командой. R8 в release ничего не вырезал (проверено).
### 5.13 Название и логотип
- `app_name` = «SleepyOwl»; так же — имя клиента для сервера (`JellyfinSession.CLIENT_NAME`, видно в «Устройствах» Jellyfin), имена профилей устройства у движков (`SleepyOwl / ExoPlayer`, `SleepyOwl / VLC`). Пакет — `ru.stenfredd.sleepyowl`.
- Исходник логотипа — `design/logo/sleepyowl.png` (сова над надписью на тёмно-синем фоне). **Картинки делает `tools/logo/make_assets.py`** (перезапустить при смене логотипа; пишет в ресурсы `ui:shared`): тёмно-синий фон вырезается («цвет в прозрачность»), свечение остаётся полупрозрачным янтарём, тело совы непрозрачное.
- `drawable-xhdpi/banner.png` — баннер Android TV 320×180 dp: сова слева, надпись справа;
- `mipmap-anydpi/ic_launcher.xml` — адаптивная иконка: сова (`drawable-xxxhdpi/ic_launcher_owl.png`, тело в круге 62 dp из 108) на `@color/ic_launcher_background` (#050927);
- `drawable-xhdpi/logo_owl.png` (36 dp) и `logo_wordmark.png` (высота 18 dp) — для интерфейса, ровно в размер на xhdpi, без масштабирования на ТВ.
- В интерфейсе — `Brand.kt` (`OwlMark`, `Wordmark`): рейл (сова над иконками, надпись появляется с открытием рейла), шапка входа, «О приложении». Проверка ресурсов — `aapt2 dump badging` (label, icon, banner).
- **Смена пакета:** applicationId, пакеты Kotlin, `namespace` модулей, имена классов приложения и темы, `rootProject.name`, baseline profile (имена классов заменяются по тексту), `tools/tv/env.sh`; после — `./gradlew clean` (сгенерированный DI-код держал старое имя класса приложения). Для системы это новое приложение; данные переносятся через debug-сборки обоих пакетов: `run-as <старый> tar -cf - files/datastore files/home` → `run-as <новый> tar -xf -` (+ `chmod 771 files`), потом release поверх. Device id переезжает вместе с данными, поэтому на сервере устройство то же.
### 5.14 Свечение фокуса без «лесенки»
- Свечение шло ступеньками с переливом цвета: `GlowCache` хранил тусклые цвета прямо в 8 битах (у R слоя `0x384678FF` всего ~15 уровней на весь хвост) — полосы по 5–10 px, R/G/B переключаются в разных местах.
- Теперь (`Glow.kt`, Android): покрытие каждого слоя рисуется в ALPHA_8 в полную силу, слои складываются во float (premultiplied), в ARGB_8888 переводятся с белым шумом ±1,5 уровня. Белый, а не IGN: маска считается в половинном разрешении и растягивается вдвое, сглаживание гасит мелкий узор. На кадре ничего не меняется (та же картинка того же размера); считать маску стало чуть дольше.
---
## 6. Особенности сервера Jellyfin 12 (сводка)
- **Авторизация:** `X-Emby-Token` сервер 12.x не принимает, работает `Authorization: MediaBrowser Token="…"`. Картинки (`/Items/{id}/Images/...`) и `/Videos/{id}/stream?static=true` отдаются без токена.
- `/Persons?searchTerm=` не ищет по кириллице — искать через `/Items?includeItemTypes=Person&searchTerm=` (§5.8).
- `/Items` со смешанными типами (`includeItemTypes=Movie,Series`) в ~10 раз медленнее одного типа (§5.7).
- `/Items/Latest?parentId` — 20–80 мс; `/Items` по дате с подсчётом — ≈0,2 с, пять параллельных — ≈1 с (§5.2).
- Ширина видео (значок «4K») — только с `fields=Width`.
- `fillWidth`/`fillHeight` сохраняют пропорции: 326×184 приходит как 327–328×184.
- `/Users/Public` пуст, если пользователи скрыты; `LocalAddress` за reverse proxy содержит публичное имя (§5.3).
- Resume отсортирован по дате просмотра; у карточки сериала есть только число непросмотренных серий.
- Снятие отметки «просмотрено» сбрасывает место остановки и `LastPlayedDate`; отметка сезона/сериала — одним запросом; при любой отметке меняется внутренний `Key` записи.
- Сервер помнит выбранные дорожки (`UserData.AudioStreamIndex/SubtitleStreamIndex`), и они важнее языковых настроек.
- Короткий просмотр (секунды) может не сохранить позицию, но выставить `LastPlayedDate` — элемент в «Историю» не попадает (там только просмотренное).
- Quick Connect: ссылка `<сервер>/web/#/quickconnect?code=…` подставляет код в веб-клиенте; код со временем забывается сервером.
- Отчёт Stopped, не дошедший до сервера (процесс убит), оставляет «висящую» сессию на сервере — некритично.
- После очистки данных приложения новый device id даёт на сервере лишнее устройство — его можно удалить в панели Jellyfin.
---
## 7. Отладка, инструменты и гигиена тестов
### 7.1 Помощники adb (`source tools/tv/env.sh`)
Адрес ТВ, сервер, `JAVA_HOME` и id сериала для профиля берутся из `local/dev.env` (шаблон `tools/dev.env.example`). Беспроводной adb на эталонном ТВ: порт сопряжения меняется после перезагрузки, но классический `<ip-тв>:5555` тоже открыт.
| Функция | Назначение |
|---|---|
| `install_debug` / `install_release` | собрать и поставить (release — сразу с компиляцией по baseline profile); debug и release ставятся друг поверх друга с сохранением входа |
| `play <id> [exo\|vlc]` | открыть карточку / запустить воспроизведение |
| `k KEY…` | нажатия клавиш |
| `shot name` | скриншот |
| `plog` | журнал решений `PlaybackSession` |
| `sess` | что играет по данным сервера: позиция, PlayMethod |
| `aout` | реально ли звук уходит на выход (AudioFlinger) |
| `vproxy` | журнал прокси VLC |
| `tv_token` | токен текущего аккаунта с ТВ (`run-as`, только debug-сборка) |
| `ui_bench [right\|down] [N]` | замер плавности (§3.3) |
| `gen_profile` | снять baseline profile заново |
Плюс `tools/tv/run-case.sh <name> <id> [engine]`, тестовый набор файлов `tools/tv/testset.json`, список для режима обхода `app/src/debug/assets/tour.json` (генератор `tools/tour/build_tour.py --server <url> --token <токен>`: жадное покрытие признаков медиатеки).
### 7.2 Приёмы
- **Код Quick Connect с экрана — текстом:** `adb exec-out uiautomator dump /dev/tty | grep -oE '[0-9]{3} [0-9]{3}'`.
- **Подтвердить Quick Connect без пароля** — уже имеющимся токеном: `curl -X POST -H 'Authorization: MediaBrowser Token="<токен>"' '<сервер>/QuickConnect/Authorize?code=<код>'`.
- **Проверить выход, не потеряв вход:** заранее получить запасной токен через Quick Connect API — `POST /QuickConnect/Initiate` с заголовком `Authorization: MediaBrowser Client=…, Device=…, DeviceId=…, Version=…` → `POST /QuickConnect/Authorize?code=` текущим токеном → `POST /Users/AuthenticateWithQuickConnect {"Secret"}`; после выхода подтвердить им код с экрана ТВ, потом отозвать (`POST /Sessions/Logout`). Токен с ТВ брать **до** `pm clear`.
- **Навигация пультом в скриптах:** «Назад» с контента — фокус в рейл, дальше ↑/↓ по пунктам; лишний «Назад» в рейле закрывает приложение. В debug главная грузится 2–4 с — сначала дождаться её, иначе фокус уйдёт обратно в контент, а OK откроет карточку. Число шагов до «Настроек» зависит от числа библиотек в рейле.
- Между нажатиями ≥ 0,25 с (§4); снимок экрана ~5 с (§5.5); долгое нажатие — только `--longpress` (§5.11).
- Ряд оболочки ТВ: `adb shell content call --uri content://ru.stenfredd.tvhome.feed --method dump` ([`launcher-feed.md`](launcher-feed.md)).
### 7.3 Гигиена тестов
- **Тесты меняют данные просмотра аккаунта** (запуск плеера, отметки, генератор baseline profile): проверять под отдельным тестовым пользователем и откатывать через API — `DELETE /UserPlayedItems/{id}` (снять просмотр), `POST /UserItems/{id}/UserData` (`Played`, позиция, `PlayCount`, `LastPlayedDate` — порядок «Продолжить просмотр» задаётся датой). Языковые настройки сервера и настройки ТВ возвращать к исходным.
- **Закрывать плеер кнопкой «Назад», а не `am force-stop`.** Если процесс убит во время воспроизведения: (1) аудио-HAL Xiaomi может продолжать играть AC3 passthrough после закрытия приложения; (2) поток (замечено на 1080i) остаётся висеть в видеоконвейере MTK, и после этого любой 4K HEVC получает `NO_MEMORY` (0xfffffff4) до перезагрузки ТВ. **Если 4K вдруг перестал декодироваться — сначала `adb reboot`.**
- Состояние ТВ влияет и на скорость интерфейса (§3.3): долгий аптайм и занятая подкачка — повод перезагрузить перед замерами.
---
## 8. Воспроизведение (что нужно знать интерфейсу)
Подробности — в [`playback.md`](playback.md). Для экранов важно:
- `PlaybackSession` (в `playback:core`) — единственная точка для UI: `setMaxBitrate`, `setSubtitleStyle`, `setSubtitleLift`, `pause`, `pauseInBackground`, ручная смена движка; в состоянии — `bufferedMs`, `itemId`, `seriesId`, `maxBitrate`, `manualEngine`, `activeEngine`. Выбор движка, fallback при сбое и транскод — внутри сессии.
- Движки подключаемые (`EngineProvider` в Koin-модуле движка): ExoPlayer — основной, libVLC — запасной.
- `android:largeHeap="true"`: без него буфер Exo доводил Java-кучу (192 МБ) до OOM; буфер ограничен ¼ кучи, не больше 64 МБ.
- Вывод видео «Авто»: SDR ≤1080p 30/60 к/с — через GPU (TextureView, обходит постобработку ТВ), HDR, >1080p, чересстрочное и 23,976/24/25/50 к/с — видеослой (SurfaceView).
---
## 9. Не сделано / отложено
- Превью кадров на таймлайне (trickplay) — нужны trickplay-картинки на сервере.
- Этап «Полировка и релиз»: состояния загрузки, пусто и ошибки в стиле макета (сейчас простые подписи «Загрузка…», «Здесь пока пусто», ошибки страницы сетки — только в логе), экран потери связи, анимации переходов, подпись release-ключом (оболочка ТВ при этом перестанет принимать ряд — см. [`launcher-feed.md`](launcher-feed.md)), APK по ABI, проверка на слабом устройстве. Пока грузятся данные (0,1–0,3 с), страница фильма пустая.
- Watch Next (androidx.tvprovider) для главного экрана Google TV.
- Голосовой поиск (нужен системный распознаватель).
- Транскод Dolby Vision-файла в Exo падает (`FragmentedMp4Extractor: Top bit not zero` на HLS fMP4 от сервера, повтор тоже падает, VLC не пробуется); обычный HEVC транскодом играет.
- Выбор вывода видео/DV в плеере не запоминается между запусками (по умолчанию — из настроек).
- В шторке плеера актёры не открывают страницу человека; шторка серий не получила меню «просмотрено»; страницы отдельной серии нет (код `DetailsScreen` для серии открывается только из intent).
- Картинка профиля с сервера не показывается (только буква).
- Монохромного слоя иконки (темы Android 13) нет; картинки интерфейса только в xhdpi (для телефона нужны xxhdpi/xxxhdpi).
- Телефон на тех же модулях данных и воспроизведения.