# Ряд «Продолжить просмотр» для оболочки ТВ: контракт (версия 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`.