# 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..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`, подпись ключом из `ANDROID_KEYSTORE` (в CI — секрет, см. development.md), локально без него — debug-ключом. Воспроизведение Exo и VLC в release проверено. - **Открытие из adb** (`am start -n ru.stenfredd.sleepyowl/.MainActivity …`), работает и в release: `--es item [--ez play true] [--el start <мс>]`; `--es series [--es episode ]` — страница сериала. Только в 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», адрес, «Дома: напрямую, »; «Сменить профиль», «Выйти» (со второго нажатия «Точно выйти?»); другие сохранённые профили — «Переключиться» и «Забыть» (тоже со второго нажатия). «Выйти» и «Забыть» отзывают токен (`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 на эталонном ТВ: порт сопряжения меняется после перезагрузки, но классический `:5555` тоже открыт. | Функция | Назначение | |---|---| | `install_debug` / `install_release` | собрать и поставить (release — сразу с компиляцией по baseline profile); debug и release ставятся друг поверх друга с сохранением входа | | `play [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 [engine]`, тестовый набор файлов `tools/tv/testset.json`, список для режима обхода `app/src/debug/assets/tour.json` (генератор `tools/tour/build_tour.py --server --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). - Телефон на тех же модулях данных и воспроизведения.