16 KiB
Ряд «Продолжить просмотр» для оболочки ТВ: контракт (версия 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. Вызов
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, как будто оболочки нет:
<queries>
<provider android:authorities="ru.stenfredd.tvhome.feed" />
</queries>
2.3 Ошибки
| Ситуация | Что приходит | Что делать |
|---|---|---|
| Оболочки нет на устройстве (другой ТВ, эмулятор) | IllegalArgumentException |
глотать молча; лучше заранее проверить packageManager.resolveContentProvider(...) == null и ничего не считать |
| Приложение подписано другим ключом | SecurityException |
записать в лог |
3. Карточка
[
{
"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. -
Сборка:
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. Проверка
# что сейчас лежит у оболочки (по источникам)
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 "[]"'
Сценарии:
- Запуск приложения → на главном экране ТВ появился ряд,
dumpсовпадает с «Продолжить просмотр» в приложении. - Начать или досмотреть что-то, выйти кнопкой Home → карточка поднялась первой с новой позицией.
- «Просмотрено» на элементе → он пропал из ряда.
- Переключить профиль → ряд другого профиля; выйти из аккаунта → ряда нет; войти обратно → ряд вернулся.
- Карточка при закрытом приложении → открылось нужное с места остановки; при открытом приложении на другом экране → тоже нужное; при открытом плеере → плеер закрылся с сохранением позиции, запустилось новое, «Назад» — страница фильма/сериала.
- Время показа главной (
Home shown inв логе) не выросло;callне на главном потоке.
6. На будущее
- Параллельно публиковать тот же список в стандартный Watch Next (
androidx.tvprovider), чтобы ряд был и на главном экране Google TV. - Код оболочки:
FeedProvider(приём, проверка подписи, разбор, хранение, загрузка обложек) и обновление ряда в еёMainActivity.