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

163 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ряд «Продолжить просмотр» для оболочки ТВ: контракт (версия 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`.