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

Что такое TV Input Framework

Это официальное решение от Google стандартизирует работу с TV-контентом. Оно дает возможность доступа к информации о TV-каналах в системной базе устройства, взаимодействия с ними:

  • получение списка передач;

  • воспроизведение контента;

  • интеграция в единый TV-интерфейс.

Данные (каналы, программы) предоставляются:

  • системными и пользовательскими TvInputService (через их реализации);

  •  системным сервисом TV Provider. 

Для устройств с поддержкой DVB (цифрового телевидения) данные могут поступать через аппаратные тюнеры или физические порты приставки.

TV Input Service 

Предоставляет доступ к источникам контента.
К таким источникам относятся:

  1. Встроенные компоненты, например аппаратный ТВ-тюнер.

  2. Внешние устройства, которые могут быть подключены через HDMI.

TV Input Service взаимодействует с ними и получает данные и видеопоток. Например, у нас есть приставка DVB с подключенным аппаратным тюнером. TV Input Service предоставит нам данные об источнике потокового вещания, о каналах и передачах. Реализация собственного сервиса описана в официальной документации.

TVProvider

Является интерфейсом, дающим доступ к базе с каналами и передачами. Через него можно получить всю информацию от источника вещания до возрастного рейтинга. При сканировании каналов с тюнера они записываются в системную базу данных, а через TVProvider мы получаем доступ к ней.

Работа TV-приложения с TIF

Полноценный флоу есть на этой официальной схеме:

Схема работы с TIF: разбираясь в ней с нуля, ты чувствуешь себя как Джерри
Схема работы с 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).

Для более детальной информации и тонких моментов можно обратиться к официальной документации, а мы пока идем дальше.

Простые, но важные моменты, которые надо учесть заранее:

  1. Только системное приложение может получать доступ ко всему хранилищу каналов и передач.

  2. После добавления каналов в TIF они привязываются к вашему пакету приложения, и другое их уже не вытащит.

  3. Не забывайте про разрешения.

Для пользовательских сервисов:
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 можно двумя способами:

  1. Чтобы запросить все доступные источники каналов, укажем uri = TvContract.Channels.CONTENT_URI.

  2. Для доступа к конкретному источнику используем идентификатор 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-приложения — важно освоить и другие части:

  1. Получение передач и отображение их расписания.

  2. Установка правил для телевизионного канала.

  3. Воспроизведение контента.

Эти вопросы я разберу в следующих статьях :)

Комментарии (1)


  1. equeim
    28.07.2025 13:22

    Главная проблема эти API в том что никто их не использует и не тестирует, и производителям устройств пофиг на баги (встроенные приложения тв каналов используют внутренние апи которые работают лучше и недоступны обычным приложениям). В итоге на многих телевизорах получение списка каналов / епг возвращает неполную информацию или вообще не работает.

    Также при воспроизведении канала через TvView банально не работают коллбэки ошибок/статусов. Например мне не удалось ни одном телевизоре получить ошибку при переключении на заскрэмблированный канал. На экране - черный квадрат Малевича, а в коллбэк ничего не приходит. Соответственно показать ошибку пользователю невозможно.