Всем привет! Меня зовут Андрей Юрин, я android-разработчик в онлайн-кинотеатре KION. При создании приложения под Android TV у вас наверняка могут возникнуть вопросы: как получить доступ к списку телевизионных каналов и как организовать у себя трансляцию? В этом материале я отвечу на них и расскажу про взаимодействие с телевизором с помощью Android TV Input Framework (TIF), а также получение через него списка доступных каналов. По сути это первый шаг к созданию полноценного TV-приложения.

Что такое TV Input Framework
Это официальное решение от Google стандартизирует работу с TV-контентом. Оно дает возможность доступа к информации о TV-каналах в системной базе устройства, взаимодействия с ними:
получение списка передач;
воспроизведение контента;
интеграция в единый TV-интерфейс.
Данные (каналы, программы) предоставляются:
системными и пользовательскими
TvInputService
(через их реализации);системным сервисом TV Provider.
Для устройств с поддержкой DVB (цифрового телевидения) данные могут поступать через аппаратные тюнеры или физические порты приставки.
TV Input Service
Предоставляет доступ к источникам контента.
К таким источникам относятся:
Встроенные компоненты, например аппаратный ТВ-тюнер.
Внешние устройства, которые могут быть подключены через HDMI.
TV Input Service взаимодействует с ними и получает данные и видеопоток. Например, у нас есть приставка DVB с подключенным аппаратным тюнером. TV Input Service предоставит нам данные об источнике потокового вещания, о каналах и передачах. Реализация собственного сервиса описана в официальной документации.
TVProvider
Является интерфейсом, дающим доступ к базе с каналами и передачами. Через него можно получить всю информацию от источника вещания до возрастного рейтинга. При сканировании каналов с тюнера они записываются в системную базу данных, а через TVProvider мы получаем доступ к ней.
Работа TV-приложения с TIF
Полноценный флоу есть на этой официальной схеме:

Реализации TVInputService предустановлены вендором в прошивке устройства. Их наличие можно проверить с помощью tvInputManager.getTvInputList()
. Обычно они уже установлены по умолчанию — если их нет, то нужно искать причину в приложении вашего провайдера или настройках.
Сама работа устройства с TIF выглядит так:
1. TVInputService по расписанию загружают каналы и передачи в системную базу TVProvider:
Данные синхронизируются через реализацию EpgSyncJobService
(Абстрактный класс, который помогает нам в создании сервиса обновления данных о каналах и передачах. Он получает информацию от TVInputService и актуализирует ее в TV Provider.)

2. Далее мы запрашиваем данные об установленных TVInputService
(содержат информацию об источниках, inputId, его типе, активности в данный момент, поддержке записи и т.д.)

3. Далее получаем необходимую информацию о каналах из TVProvider с помощью контракта TvContract. Затем в приложении выводим их на экран.
4. Пользователь взаимодействует с нашим списком:
Мы отслеживаем его клик и передаем данные о нужном канале в систему, которая определяет, к какому сервису TVInputService он относится.
5. Приложение создает сессию для конкретного просмотра TVInputService.Session через TVInputManager, который управляет взаимодействием с источником:

После настройки сессии она определяет, как будем получать поток для данного телеканала (тюнер, url).
Для более детальной информации и тонких моментов можно обратиться к официальной документации, а мы пока идем дальше.
Простые, но важные моменты, которые надо учесть заранее:
Только системное приложение может получать доступ ко всему хранилищу каналов и передач.
После добавления каналов в TIF они привязываются к вашему пакету приложения, и другое их уже не вытащит.
Не забывайте про разрешения.
Для пользовательских сервисов:android.permission.READ_TV_LISTINGS
— доступ к каналам, передачам, расписанию и т. д.
Для системных:com.android.providers.tv.permission.READ_EPG_DATA
— чтение передач;com.android.providers.tv.permission.WRITE_EPG_DATA
— запись передач;com.android.providers.tv.permission.ACCESS_ALL_EPG_DATA
— полный доступ к передачам.
Стандартная реализация
Первое, что нам нужно, — стартовый экран с сеткой, на котором мы выведем наши телеканалы. У него будет три состояния: загрузка, ошибка и успешное получение данных.
// Главный экран для отображения каналов
@Composable
fun TVScreen(
viewModel: TVViewModel = koinViewModel(),
navController: NavController,
) {
val state by viewModel.channelsState.collectAsState()
when (val data = state) {
TVState.Loading -> Loading()
is TVState.Success -> ListChannels(
channels = data.channels,
onClick = {
navController.navigate(route = TvViewScreen(it))
}
)
is TVState.Error -> Error()
}
}
// Отображение в виде сетки
@Composable
private fun ListChannels(
channels: List<ChannelModel>,
onClick: (ChannelModel) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(5),
) {
items(channels) { channel ->
ChannelItem(channel, onClick)
}
}}
// Карточка канала
@Composable
private fun ChannelItem(channel: ChannelModel, onClick: (ChannelModel) -> Unit) {
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 5.dp)
.sizeIn(minHeight = 200.dp)
.background(
if (isFocused) {
Color(0x1A00FFFF)
} else {
Color(0xFF00FFFF)
}
)
.clickable {
onClick.invoke(channel)
}
.onFocusChanged {
isFocused = it.isFocused
}
.focusable(),
contentAlignment = Alignment.Center
) {
Text(text = channel.name.orDefault("no channel"))
}
}
TVViewModel.kt
class TVViewModel(
private val tifManager: TifManager
) : ViewModel() {
private val _channelsState = MutableStateFlow<TVState>(TVState.Loading)
val channelsState = _channelsState.asStateFlow()
init {
initChannels()
}
fun initChannels() {
viewModelScope.launch {
runCatching { tifManager.getAllChannels() }
.onSuccess { channels ->
_channelsState.emit(
TVState.Success(
channels
.filter { it.number != null }
)
)
}
.onFailure { _channelsState.emit(TVState.Error(it.localizedMessage.orEmpty())) }
}
}
}
Следующим шагом мы создадим TifManager — собственную прослойку между приложением и TIF. Для получения каналов из TIF мы используем TvProvider — компонент Android, позволяющий обмениваться данными между приложениями.
Указать URI можно двумя способами:
Чтобы запросить все доступные источники каналов, укажем
uri = TvContract.Channels.CONTENT_URI
.Для доступа к конкретному источнику используем идентификатор tvInputId, например
uri = TvContract.buildChannelsUriForInput("com.test.tvinput/.test.TvInputService")
.
Теперь указываем, что нам необходимо получить:
val projection = arrayOf(
TvContract.Channels.COLUMN_DISPLAY_NAME,
TvContract.Channels.COLUMN_DISPLAY_NUMBER,
TvContract.Channels.COLUMN_INPUT_ID,
TvContract.Channels._ID
)
В этом фрагменте:TvContract.Channels.COLUMN_DISPLAY_NAME
— название канала;
TvContract.Channels.COLUMN_DISPLAY_NUMBER
— номер канала;
TvContract.Channels.COLUMN_INPUT_ID
— идентификатор, который связывает с источником TV-контента;
TvContract.Channels._ID
— Id канала.
Также нужна модель данных, которую мы будем передавать к нам ChannelModel
:
data class ChannelModel(
val name: String?,
val number: String?,
val tvInputId: String?,
val id: String,
val channelUri: String
)
Появляется новое поле channelUri. Это идентификатор канала в формате content://
, который используется системой. Он нужен для воспроизведения и появляется в процессе получения всех каналов методом TvContract.buildChannelUri(id)
.
Итоговая реализация выглядит так:
private val uri = TvContract.Channels.CONTENT_URI
private val contentResolver: ContentResolver = context.contentResolver
val projection = arrayOf(
TvContract.Channels.COLUMN_DISPLAY_NAME,
TvContract.Channels.COLUMN_DISPLAY_NUMBER,
TvContract.Channels.COLUMN_INPUT_ID,
TvContract.Channels._ID
)
override suspend fun getAllChannels(): List<ChannelModel> = withContext(Dispatchers.Default) {
val cursor = contentResolver.query(uri, projection, null, null, null)
buildList {
cursor?.use { c ->
val nameIndex = c.getColumnIndex(TvContract.Channels.COLUMN_DISPLAY_NAME)
val numberIndex = c.getColumnIndex(TvContract.Channels.COLUMN_DISPLAY_NUMBER)
val inputIdIndex = c.getColumnIndex(TvContract.Channels.COLUMN_INPUT_ID)
val channelIdIndex = c.getColumnIndex(TvContract.Channels._ID)
while (c.moveToNext()) {
val name = c.getString(nameIndex)
val number = c.getString(numberIndex)
val inputId = c.getString(inputIdIndex)
val id = c.getLong(channelIdIndex)
val channelUri = TvContract.buildChannelUri(id)
val finalChannelModel = ChannelModel(
tvInputId = inputId,
name = name,
number = number,
channelUri = channelUri.toString(),
id = id.toString()
)
add(finalChannelModel)
}
} ?: errorChannelNotFound()
}
}
С ней также можно ознакомиться на Github.
На этом примере я показал базовую реализацию работы TV-приложения (получение каналов). Использование Android TIF упрощает разработку, так как он дает готовую инфраструктуру и позволяет не заботиться о реализации своих компонентов и их взаимодействии.
Но это был лишь первый шаг для создания полноценного TV-приложения — важно освоить и другие части:
Получение передач и отображение их расписания.
Установка правил для телевизионного канала.
Воспроизведение контента.
Эти вопросы я разберу в следующих статьях :)
equeim
Главная проблема эти API в том что никто их не использует и не тестирует, и производителям устройств пофиг на баги (встроенные приложения тв каналов используют внутренние апи которые работают лучше и недоступны обычным приложениям). В итоге на многих телевизорах получение списка каналов / епг возвращает неполную информацию или вообще не работает.
Также при воспроизведении канала через TvView банально не работают коллбэки ошибок/статусов. Например мне не удалось ни одном телевизоре получить ошибку при переключении на заскрэмблированный канал. На экране - черный квадрат Малевича, а в коллбэк ничего не приходит. Соответственно показать ошибку пользователю невозможно.