163 lines
16 KiB
Markdown
163 lines
16 KiB
Markdown
# Ряд «Продолжить просмотр» для оболочки ТВ: контракт (версия 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
|
||
<queries>
|
||
<provider android:authorities="ru.stenfredd.tvhome.feed" />
|
||
</queries>
|
||
```
|
||
|
||
### 2.3 Ошибки
|
||
|
||
| Ситуация | Что приходит | Что делать |
|
||
|---|---|---|
|
||
| Оболочки нет на устройстве (другой ТВ, эмулятор) | `IllegalArgumentException` | глотать молча; лучше заранее проверить `packageManager.resolveContentProvider(...) == null` и ничего не считать |
|
||
| Приложение подписано другим ключом | `SecurityException` | записать в лог |
|
||
|
||
## 3. Карточка
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": "<id элемента Jellyfin, 32 hex без дефисов>",
|
||
"title": "Сериал · S2 E5",
|
||
"subtitle": "осталось 12 мин",
|
||
"progress": 0.24,
|
||
"image": "http://<адрес-сервера-в-LAN>:8096/Items/<id>/Images/Primary?fillWidth=326&fillHeight=184&quality=90&tag=<tag>",
|
||
"intent": "intent:#Intent;component=ru.stenfredd.sleepyowl/.MainActivity;S.item=<id>;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 <ip-тв>:5555 shell content call --uri content://ru.stenfredd.tvhome.feed --method dump
|
||
|
||
# подставить список от имени приложения без самого приложения;
|
||
# JSON — только в --arg: в --extra утилита content не принимает двоеточия
|
||
adb -s <ip-тв>: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`.
|