# Ряд «Продолжить просмотр» для оболочки ТВ: контракт (версия 1) Своя домашняя оболочка Android TV (пакет `ru.stenfredd.tvhome`, отдельный проект) показывает ряд «Продолжить просмотр». Содержимое ряда присылает SleepyOwl (`ru.stenfredd.sleepyowl`). Документ описывает контракт между ними и то, как его выполняет приложение. Менять контракт можно только одновременно в обоих проектах. ## 1. Модель: приложение само присылает список Приложение отправляет оболочке **готовый список карточек, когда он меняется**. Оболочка хранит его в файле и показывает; при нажатии Home она ничего не спрашивает и процесс приложения не будит. Почему не иначе: - **Провайдер в приложении, который опрашивает оболочка** — каждый возврат на главный экран поднимал бы процесс приложения (DI, OkHttp, десятки МБ, ~0,5 с процессора). Это противоречит смыслу лёгкой оболочки. - **Стандартный Watch Next / «Play Next» (TvProvider)** — проверено на эталонном ТВ: сторонний лаунчер чужие записи не видит. Запись от другого пакета даёт оболочке 0 строк даже с `READ_EPG_DATA`; нужен `ACCESS_ALL_EPG_DATA`, а он `signature|privileged`. - **Виджеты (AppWidget)** — RemoteViews: нет свечения и увеличения из макета, плохо с фокусом пульта, тяжелее. - **Собственный вход оболочки в Jellyfin** — второй логин и только один заранее выбранный профиль. ## 2. Вызов ```kotlin context.contentResolver.call( Uri.parse("content://ru.stenfredd.tvhome.feed"), "continue_watching", // метод json, // arg: JSON-массив карточек строкой null, // extras ) // → Bundle { "version" = 1 } ``` | Параметр | Значение | |---|---| | Authority провайдера | `ru.stenfredd.tvhome.feed` | | Метод | `continue_watching` | | `arg` | JSON-массив карточек (§3) строкой | | `extras` | `null` | | Ответ | `Bundle` с `"version" = 1` (версия контракта) | - **Пустой массив `[]` прячет ряд.** Отправлять при выходе из аккаунта и когда продолжать нечего. - **Порядок в массиве = порядок в ряду.** Оболочка принимает до 20 карточек, показывает первые 5. Приложение шлёт 10 (запас). - Вызов делать не на главном потоке. ### 2.1 Доступ и подпись - Оболочка принимает вызов **только от приложения, подписанного тем же ключом**, что и она (`checkSignatures` по uid вызывающего). Чужой ключ → `SecurityException`. - **Смена ключа подписи приложения** (например, переход с debug-ключа на release-ключ) отключит ряд. Тогда либо подписать оболочку тем же ключом, либо научить её проверять конкретный сертификат приложения. - Вызовы из `adb shell` оболочка тоже принимает (для отладки, §5). ### 2.2 Видимость пакетов На targetSdk 30+ без записи в манифесте вызывающего `call` падает с `IllegalArgumentException: Unknown authority`, как будто оболочки нет: ```xml ``` ### 2.3 Ошибки | Ситуация | Что приходит | Что делать | |---|---|---| | Оболочки нет на устройстве (другой ТВ, эмулятор) | `IllegalArgumentException` | глотать молча; лучше заранее проверить `packageManager.resolveContentProvider(...) == null` и ничего не считать | | Приложение подписано другим ключом | `SecurityException` | записать в лог | ## 3. Карточка ```json [ { "id": "", "title": "Сериал · S2 E5", "subtitle": "осталось 12 мин", "progress": 0.24, "image": "http://<адрес-сервера-в-LAN>:8096/Items//Images/Primary?fillWidth=326&fillHeight=184&quality=90&tag=", "intent": "intent:#Intent;component=ru.stenfredd.sleepyowl/.MainActivity;S.item=;B.play=true;l.start=<мс>;end" } ] ``` | Поле | Обяз. | Смысл | |---|---|---| | `id` | да | id элемента Jellyfin; по нему оболочка удерживает фокус при обновлении ряда | | `title` | да | одна строка, влезает ~28 символов (шрифт 19 px в карточке 326 px). «Сериал · S2 E5» или название фильма; название серии не влезет. У серии без номеров на сервере — только название сериала | | `subtitle` | нет | «осталось 31 мин» у начатого, «следующая серия» у следующей. Оболочка сама добавляет перед ним название приложения («SleepyOwl · …») | | `progress` | нет | 0..1; нет поля или `null` — без полоски (следующая серия) | | `image` | нет | картинка 16:9 размером 326×184 px (§3.1) | | `intent` | да | `Intent.toUri(Intent.URI_INTENT_SCHEME)` (§3.2) | ### 3.1 Картинка - Оболочка качает её простым `HttpURLConnection` **без токена** и кладёт на диск с ключом по URL. Jellyfin отдаёт картинки без авторизации; HTTP в локальной сети у оболочки разрешён. - Источник — как у широкой карточки приложения: кадр серии → фон → thumb. - **Адрес должен быть уже переписан на LAN-адрес сервера**, если он известен (иначе — публичный адрес). В самом приложении адреса картинок строятся на публичном адресе и переводятся на LAN перехватчиком OkHttp (`ServerRouting`), а у оболочки такого перехватчика нет. - **Параметр `tag` оставлять:** при смене картинки на сервере меняется URL, и оболочка скачает её заново. - `fillWidth`/`fillHeight` сохраняют пропорции, поэтому сервер отдаёт 327–328×184 вместо 326×184; оболочка режет по центру, это незаметно. ### 3.2 Intent - Оболочка разбирает строку через `Intent.parseUri` и запускает интент, **только если компонент или пакет — `ru.stenfredd.sleepyowl`**. Сама добавляет `FLAG_ACTIVITY_NEW_TASK` и снимает флаги выдачи прав на URI. - Сборка: ```kotlin Intent().setClassName("ru.stenfredd.sleepyowl", MainActivity::class.java.name) .putExtra("item", id) // String, id элемента .putExtra("play", true) // сразу воспроизведение .putExtra("start", positionMs) // Long, место остановки; у следующей серии не передаётся .putExtra("series", seriesId) // String, только у серии .toUri(Intent.URI_INTENT_SCHEME) ``` | Экстра | Тип | Смысл | |---|---|---| | `item` | String | элемент для воспроизведения | | `play` | Boolean | `true` — сразу плеер | | `start` | Long | позиция в мс. **Без неё плеер начнёт с нуля** (и элемент выпадет из «Продолжить просмотр»); у следующей серии без прогресса не нужна | | `series` | String | id сериала: под плеером окажется страница сериала на этой серии, а не страница серии | **Поведение приложения при запуске с карточки:** сразу воспроизведение с места остановки (у карточки в фокусе оболочки кнопка ▶). Стек — «главная приложения → страница фильма (или страница сериала на этой серии) → плеер», так что «Назад» из плеера ведёт на страницу, а дальше на главную приложения. Запомненные для фильма или сериала дорожки звука и субтитров подхватываются. Требования к приёмной стороне (выполнены в `MainActivity`): - `android:launchMode="singleTask"` + `onNewIntent(intent)` → `setIntent(intent)` и передача цели в Compose. Без этого при открытом приложении запуск с `NEW_TASK` лишь поднимает старую задачу: цель читается один раз при создании, и открывается прежний экран. - По новой цели стек сбрасывается так же, как при холодном старте. Открытый в этот момент плеер закрывается и отправляет позицию на сервер, как при обычном выходе. - Пользователь не вошёл (экран входа) — цель игнорируется. Запуск без цели (иконка приложения) экран не меняет. ## 4. Когда и что отправляет приложение Реализация — `LauncherFeed` в модуле `app` (интент ссылается на `MainActivity`, поэтому не в общих модулях). - **Старт:** из `Application.onCreate` одной корутиной на `Dispatchers.IO`, без работы на старте: ждёт восстановления сессии (`JellyfinSession.restored` — до него «нет сессии» не значит «вышли») и ещё 3 с, чтобы не мешать главной. Время показа главной от этого не меняется. - **Пересчёт:** при старте процесса; при смене аккаунта (вход, переключение профиля, выход); по событиям изменения пользовательских данных (`SessionCache.invalidations`: остановка плеера, «просмотрено», избранное). Пачки событий схлопываются за ~1 с. - **Содержимое:** сессии нет → `[]`; иначе `WatchRepository.continueWatching()` (тот же вызов и тот же ключ кэша, что у главной приложения, — лишних запросов нет) → первые 10 → JSON. Один тайтл — одна карточка: от сериала остаётся последняя по времени просмотра начатая серия; дальше — следующие серии сериалов без начатых. - **Отправка — только если строка JSON отличается** от последней отправленной. - **Ошибка сети — ничего не отправлять:** у оболочки остаётся прошлый список. - **Выход из плеера кнопкой Home:** плеер на `ON_STOP` ставит паузу, отправляет позицию на сервер и сообщает об изменении данных → пересчёт → оболочка получает новую позицию через 1–2 с. ### Ограничения - Если процесс приложения убит сразу после Home из плеера (до ответа сервера), у оболочки остаётся старая позиция до следующего запуска приложения. Отправлять поправленный список, не дожидаясь сервера, можно, но не сделано. - Просмотр на других устройствах (телефон, веб) оболочка увидит только после следующего запуска приложения. Периодической фоновой работы (WorkManager) нет намеренно: на эталонном ТВ фоновая активность сознательно сведена к минимуму. ## 5. Проверка ```bash # что сейчас лежит у оболочки (по источникам) adb -s :5555 shell content call --uri content://ru.stenfredd.tvhome.feed --method dump # подставить список от имени приложения без самого приложения; # JSON — только в --arg: в --extra утилита content не принимает двоеточия adb -s :5555 shell 'content call --uri content://ru.stenfredd.tvhome.feed --method continue_watching --extra source:s:ru.stenfredd.sleepyowl --arg "[]"' ``` Сценарии: 1. Запуск приложения → на главном экране ТВ появился ряд, `dump` совпадает с «Продолжить просмотр» в приложении. 2. Начать или досмотреть что-то, выйти кнопкой Home → карточка поднялась первой с новой позицией. 3. «Просмотрено» на элементе → он пропал из ряда. 4. Переключить профиль → ряд другого профиля; выйти из аккаунта → ряда нет; войти обратно → ряд вернулся. 5. Карточка при закрытом приложении → открылось нужное с места остановки; при открытом приложении на другом экране → тоже нужное; при открытом плеере → плеер закрылся с сохранением позиции, запустилось новое, «Назад» — страница фильма/сериала. 6. Время показа главной (`Home shown in` в логе) не выросло; `call` не на главном потоке. ## 6. На будущее - Параллельно публиковать тот же список в стандартный Watch Next (`androidx.tvprovider`), чтобы ряд был и на главном экране Google TV. - Код оболочки: `FeedProvider` (приём, проверка подписи, разбор, хранение, загрузка обложек) и обновление ряда в её `MainActivity`.