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

16 KiB
Raw Permalink Blame History

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

Сценарии:

  1. Запуск приложения → на главном экране ТВ появился ряд, dump совпадает с «Продолжить просмотр» в приложении.
  2. Начать или досмотреть что-то, выйти кнопкой Home → карточка поднялась первой с новой позицией.
  3. «Просмотрено» на элементе → он пропал из ряда.
  4. Переключить профиль → ряд другого профиля; выйти из аккаунта → ряда нет; войти обратно → ряд вернулся.
  5. Карточка при закрытом приложении → открылось нужное с места остановки; при открытом приложении на другом экране → тоже нужное; при открытом плеере → плеер закрылся с сохранением позиции, запустилось новое, «Назад» — страница фильма/сериала.
  6. Время показа главной (Home shown in в логе) не выросло; call не на главном потоке.

6. На будущее

  • Параллельно публиковать тот же список в стандартный Watch Next (androidx.tvprovider), чтобы ряд был и на главном экране Google TV.
  • Код оболочки: FeedProvider (приём, проверка подписи, разбор, хранение, загрузка обложек) и обновление ряда в её MainActivity.