Основная функция создания

Библиотека media3-ui-compose содержит базовые компоненты для создания интерфейса медиапроигрывателя в Jetpack Compose. Он предназначен для разработчиков, которым требуется больше возможностей настройки, чем предлагает библиотека media3-ui-compose-material3. На этой странице объясняется, как использовать основные компоненты и держатели состояний для создания пользовательского интерфейса медиаплеера.

Сочетание Material3 и пользовательских компонентов Compose

Библиотека media3-ui-compose-material3 разработана так, чтобы быть гибкой. Вы можете использовать готовые компоненты для большей части интерфейса, но заменять отдельные компоненты на пользовательские, когда вам нужен больший контроль. Здесь на помощь приходит библиотека media3-ui-compose.

Например, вы хотите использовать стандартные PreviousButton и NextButton из библиотеки Material3, но вам нужен полностью настраиваемый PlayPauseButton. Для этого используйте PlayPauseButton из основной библиотеки media3-ui-compose и разместите его рядом со встроенными компонентами.

Row {
  // Use prebuilt component from the Media3 UI Compose Material3 library
  PreviousButton(player)
  // Use the scaffold component from Media3 UI Compose library
  PlayPauseButton(player) {
    // `this` is PlayPauseButtonState
    FilledTonalButton(
      onClick = {
        Log.d("PlayPauseButton", "Clicking on play-pause button")
        this.onClick()
      },
      enabled = this.isEnabled,
    ) {
      Icon(
        imageVector = if (showPlay) Icons.Default.PlayArrow else Icons.Default.Pause,
        contentDescription = if (showPlay) "Play" else "Pause",
      )
    }
  }
  // Use prebuilt component from the Media3 UI Compose Material3 library
  NextButton(player)
}

Доступные компоненты

Библиотека media3-ui-compose содержит набор готовых composable-функций для распространенных элементов управления проигрывателем. Вот некоторые компоненты, которые вы можете использовать непосредственно в своем приложении:

Компонент Описание
PlayPauseButton Контейнер состояния для кнопки, которая переключается между воспроизведением и паузой.
SeekBackButton Контейнер состояния для кнопки, которая перематывает назад на заданный интервал.
SeekForwardButton Контейнер состояния для кнопки, которая перематывает вперед на заданный интервал.
NextButton Контейнер состояния для кнопки, которая переходит к следующему мультимедийному объекту.
PreviousButton Контейнер состояния для кнопки, которая переходит к предыдущему медиаобъекту.
RepeatButton Контейнер состояния для кнопки, которая переключает режимы повтора.
ShuffleButton Контейнер состояния для кнопки, которая включает и отключает воспроизведение в случайном порядке.
MuteButton Контейнер состояния для кнопки, которая включает и отключает звук в проигрывателе.
TimeText Контейнер состояния для composable-функции, которая показывает прогресс проигрывателя.
ContentFrame Область для медиаконтента, которая управляет соотношением сторон, изменением размера, затвором, обложкой и слотом для субтитров.
PlayerSurface Необработанная поверхность, которая оборачивает SurfaceView и TextureView в AndroidView.
Artwork Composable-функция, которая показывает обложку текущего медиаобъекта, например альбома.

Настройка composable-функции Player

При использовании библиотеки media3-ui-compose-material3 composable-функция Player обеспечивает полноценное воспроизведение медиаконтента. Вы можете настроить макет, добавив собственные composable-функции в слоты контента, или использовать объект PlayerDefaults, чтобы настроить определенные части пользовательского интерфейса, такие как TopControls, CenterControls, BottomControls и ErrorOverlay.

На рисунке 1 показано, где composable-функция Player размещает свои слоты. На рисунке 2 показан порядок, в котором рисуются слои.

Видеопроигрыватель с пунктирными контурами, обозначающими рекламные места. Сверху вниз: topControls вдоль верхнего края, errorOverlay над центром, centerControls в середине с кнопками backSecondary, back, central, forward и forwardSecondary, субтитры внизу и bottomControls вдоль нижнего края со слотами above, left, progressSlider, right и below. ContentFrame заполняет область за ними.
Рисунок 1. Слоты composable-функции Player. Элементы управления в центре и внизу показывают слоты PlayerDefaults.CenterControls и PlayerDefaults.BottomControls. По умолчанию слоты topControls, above и below пусты.
Взрыв-схема шести слоев, расположенных друг над другом. Снизу вверх: 1, PlayerSurface; 2, shutter; 3, artwork; 4, subtitles; 5, errorOverlay; и 6, topControls, centerControls и bottomControls. Слои с первого по четвертый составляют ContentFrame, а все шесть – Player.
Рисунок 2. Слои composable-функции Player, пронумерованные в порядке их отрисовки.

Например, с помощью PlayerDefaults.CenterControls можно переопределить только центральную кнопку воспроизведения/паузы, оставив остальные элементы управления без изменений. Вы также можете передать полностью настраиваемую composable-функцию для параметра, например topControls, и полностью опустить другие, например bottomControls, чтобы оставить их по умолчанию.

@Composable
fun CustomPlayerSlots(player: Player, showControls: Boolean, modifier: Modifier = Modifier) {
  Player(
    player = player,
    modifier = modifier,
    showControls = showControls,
    topControls = {
      // Fully custom top controls
      Text("My custom title")
    },
    centerControls = {
      // Use default CenterControls but override the central button
      PlayerDefaults.CenterControls(
        player = player,
        central = {
          // A custom play/pause button
          Material3PlayPauseButton(player, modifier = Modifier.size(64.dp))
        },
      )
    },
    // bottomControls are left as default
  )
}

Функция Player также показывает субтитры и, если видео нет, обложку текущего медиаобъекта. Чтобы настроить их внешний вид, передайте собственные composable-функции в слоты subtitles и artwork. Чтобы скрыть их, передайте пустую composable-функцию в subtitles и null в artwork.

Хранилища состояния в интерфейсе

Если ни один из компонентов каркаса не соответствует вашим потребностям, вы также можете использовать объекты состояния напрямую. Как правило, рекомендуется использовать соответствующие методы remember, чтобы сохранить внешний вид пользовательского интерфейса между рекомпозициями.

Чтобы лучше понять, как использовать гибкость хранилищ состояния пользовательского интерфейса по сравнению с composable-функциями, прочитайте о том, как Compose управляет состоянием.

Хранилища состояния кнопок

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

Штат remember*State Тип
PlayPauseButtonState rememberPlayPauseButtonState 2-Toggle или N-Toggle
PreviousButtonState rememberPreviousButtonState Константа
NextButtonState rememberNextButtonState Константа
RepeatButtonState rememberRepeatButtonState 3-Toggle
ShuffleButtonState rememberShuffleButtonState 2-Toggle
PlaybackSpeedState rememberPlaybackSpeedState Меню или переключатель N-Toggle

Помимо состояний воспроизведения и паузы, PlayPauseButtonState предоставляет showBuffering, showReplay и showRetry. Используйте их, чтобы показывать индикатор буферизации, значок повтора после окончания воспроизведения и значок повторной попытки после ошибки воспроизведения. showReplay и showRetry уточняют showPlay: если одно из них равно true, то showPlay также равно true, поэтому проверяйте их перед showPlay. Метод onClick также обрабатывает эти состояния, например подготавливает проигрыватель после ошибки.

Чтобы выбрать, какие из этих состояний показывать, передайте комбинацию SHOW_BUFFERING_WHEN_PLAYING, SHOW_BUFFERING_ALWAYS, SHOW_REPLAY_ON_ENDED и SHOW_RETRY_ON_ERROR в параметр displayMode метода rememberPlayPauseButtonState. По умолчанию displayMode объединяет SHOW_BUFFERING_ALWAYS, SHOW_REPLAY_ON_ENDED и SHOW_RETRY_ON_ERROR, что позволяет показывать все три состояния, а состояние буферизации появляется даже во время паузы.

Пример использования PlayPauseButtonState:

val state = rememberPlayPauseButtonState(player)

IconButton(onClick = state::onClick, modifier = modifier, enabled = state.isEnabled) {
  Icon(
    imageVector = if (state.showPlay) Icons.Default.PlayArrow else Icons.Default.Pause,
    contentDescription =
      if (state.showPlay) stringResource(R.string.playpause_button_play)
      else stringResource(R.string.playpause_button_pause),
  )
}

Хранилища состояния визуального вывода

CurrentMediaItemState предоставляет информацию о текущем воспроизводимом MediaItem, а PlaylistState – о MediaItems, установленном в проигрывателе. Это полезно для отображения метаданных в пользовательском интерфейсе.

ErrorState предоставляет информацию о текущем состоянии ошибки проигрывателя, которая может использоваться для показа оверлея с ошибкой.

PlaybackState предоставляет доступ к основному состоянию воспроизведения проигрывателя, например playbackState, isPlaying и playerError, независимо от конкретного компонента пользовательского интерфейса. Используйте его для управления оверлеями или пользовательскими макетами, например для отображения индикатора загрузки во время буферизации проигрывателя. Для встроенного индикатора Material3 используйте BufferingIndicator.

PresentationState содержит информацию о том, когда видеовыход в PlayerSurface может быть показан или должен быть закрыт элементом интерфейса плейсхолдера. ContentFrame composable-функция сочетает в себе обработку соотношения сторон с заботой о показе затвора над поверхностью, которая ещё не готова.

@Composable
fun ContentFrame(
  player: Player?,
  modifier: Modifier = Modifier,
  surfaceType: @SurfaceType Int = SURFACE_TYPE_SURFACE_VIEW,
  contentScale: ContentScale = ContentScale.Fit,
  keepContentOnReset: Boolean = false,
  shutter: @Composable () -> Unit = { Box(Modifier.fillMaxSize().background(Color.Black)) },
) {
  val presentationState = rememberPresentationState(player, keepContentOnReset)
  val scaledModifier =
    modifier.resizeWithContentScale(contentScale, presentationState.videoAspectRatio)

  // Always leave PlayerSurface to be part of the Compose tree because it will be initialized in
  // the process. If this composable is guarded by some condition, it might never become visible
  // because the Player won't emit the relevant event, e.g. the first frame being ready.
  PlayerSurface(player, scaledModifier, surfaceType)

  if (presentationState.coverSurface) {
    // Cover the surface that is being prepared with a shutter
    shutter()
  }
}

Здесь мы можем использовать как presentationState.videoAspectRatio, чтобы масштабировать Surface до выбранного соотношения сторон (другие типы см. в разделе Масштаб контента), так и presentationState.coverSurface, чтобы узнать, когда не следует показывать Surface. В этом случае вы можете разместить поверх области непрозрачную шторку, которая исчезнет, когда область будет готова. ContentFrame позволяет настроить затвор в виде завершающего лямбда-выражения, но по умолчанию это будет черный @Composable Box, заполняющий размер родительского контейнера.

PresentationState также предоставляет showArtwork, который равен true, когда текущий мультимедийный объект воспроизводится без видеодорожки, например контент только со звуком. В этом случае ContentFrame отображает компонент Artwork, который показывает обложку из MediaMetadata элемента. Чтобы настроить или скрыть обложку, передайте собственную composable-функцию или null в слот artwork функции ContentFrame.

По умолчанию Artwork загружает только данные обложки, встроенные в метаданные, или из URI content, file или android.resource. Чтобы загрузить обложку из удаленных URI или других источников, воспользуйтесь одним из следующих вариантов:

  • Передайте BitmapLoader, например DataSourceBitmapLoader, в Artwork.
  • Используйте перегрузку Artwork, которая принимает painter, чтобы загружать изображения с помощью выбранной вами библиотеки.

В отличие от composable-функции Player, которая по умолчанию отображает субтитры, ContentFrame оставляет слот subtitles пустым. Чтобы отобразить субтитры в ContentFrame или настроить их отображение в Player, отслеживайте текущие подсказки проигрывателя с помощью CuesState и отображайте их в слоте subtitles. ContentFrame размещает этот слот в границах масштабированного видео и рисует его над затвором и обложкой, чтобы субтитры оставались видимыми, когда видео нет.

Отслеживание хранилищ состояния выбора

TrackSelectionState помогает создать меню для одного типа дорожки, например для языка аудио или субтитров. Создайте его с помощью rememberTrackSelectionState, передав тип дорожки и комбинацию фильтров, например TRACK_FILTER_LANGUAGE или TRACK_FILTER_RESOLUTION, которые определяют, какие свойства дорожки отличают варианты. Задайте для hasOffOption или hasAutoOption значение true, чтобы добавить параметр, который отключает тип дорожки или позволяет проигрывателю выбирать дорожки автоматически.

В этом состоянии доступны selectionOptions и selectedOption. Каждый элемент TrackSelectionOption указывает, поддерживается ли он устройством и выбран ли он. Передача параметра в selectOption соответствующим образом обновляет параметры выбора дорожки проигрывателя. Прежде чем показывать меню, проверьте canSetTrackSelectionParameters и убедитесь, что вариантов достаточно. Например, в меню субтитров ниже предлагается по одному варианту для каждого языка субтитров и вариант отключения субтитров:

@Composable
fun SubtitleMenu(player: Player, modifier: Modifier = Modifier) {
  val state =
    rememberTrackSelectionState(
      player,
      trackType = C.TRACK_TYPE_TEXT,
      trackFilters = TRACK_FILTER_LANGUAGE,
      hasOffOption = true,
    )
  // Hide the menu if the player can't change tracks or has no subtitles to choose from
  if (!state.canSetTrackSelectionParameters || state.selectionOptions.size < 2) return

  Column(modifier.selectableGroup()) {
    for (option in state.selectionOptions) {
      Row(
        Modifier.fillMaxWidth()
          .heightIn(min = 48.dp)
          .selectable(
            selected = option.isSelected,
            enabled = option.isSupported,
            role = Role.RadioButton,
            onClick = { state.selectOption(option) },
          )
          .padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.CenterVertically,
      ) {
        RadioButton(selected = option.isSelected, onClick = null, enabled = option.isSupported)
        Text(
          text =
            if (option.onOffState == TRACK_SELECTION_OFF) "Off"
            else option.language?.let { Locale.forLanguageTag(it).displayName } ?: "Unknown",
          color =
            if (option.isSupported) Color.Unspecified
            else LocalContentColor.current.copy(alpha = 0.38f),
        )
      }
    }
  }
}

Если вам нужен прямой доступ к Tracks и TrackSelectionParameters игрока, используйте TrackSelectionParametersState. Подробнее о том, как проигрыватель выбирает дорожки…

Где найти Flows?

Многие разработчики Android используют объекты Kotlin Flow для сбора постоянно меняющихся данных пользовательского интерфейса. Например, вы можете искать поток Player.isPlaying, который можно collect с учетом жизненного цикла. Или что-то вроде Player.eventsFlow, чтобы получить Flow<Player.Events>, который можно filter так, как вам нужно.

Однако использование потоков для состояния пользовательского интерфейса Player имеет некоторые недостатки. Одна из основных проблем – асинхронный характер передачи данных. Мы хотим добиться минимальной задержки между Player.Event и его использованием на стороне пользовательского интерфейса, избегая показа элементов пользовательского интерфейса, которые не синхронизированы с Player.

Другие важные моменты:

  • Поток со всеми Player.Events не будет соответствовать принципу единой ответственности, и каждому потребителю придется отфильтровывать нужные события.
  • Если вы создадите поток для каждого Player.Event, вам потребуется объединить их (с помощью combine) для каждого элемента интерфейса. Между Player.Event и изменением элемента интерфейса существует сопоставление "многие ко многим". Использование combine может привести к потенциально недопустимым состояниям интерфейса.

Создание пользовательских состояний пользовательского интерфейса

Если существующие состояния интерфейса не соответствуют вашим потребностям, вы можете добавить собственные. Посмотрите исходный код существующего состояния, чтобы скопировать шаблон. Типичный класс хранилища состояния пользовательского интерфейса выполняет следующие действия:

  1. Принимает объект Player.
  2. Подписывается на Player с помощью сопрограмм. Подробнее: Player.listen.
  3. Реагирует на определенные Player.Events, обновляя свое внутреннее состояние.
  4. Принимает команды бизнес-логики, которые будут преобразованы в соответствующее обновление Player.
  5. Может быть создан в нескольких местах дерева пользовательского интерфейса и всегда будет поддерживать согласованное представление состояния проигрывателя.
  6. Предоставляет поля Compose State, которые могут использоваться composable-функцией для динамического реагирования на изменения.
  7. Поставляется с функцией remember*State для запоминания экземпляра между композициями.

Что происходит за кулисами:

class SomeButtonState(private val player: Player) {
  var isEnabled by mutableStateOf(player.isCommandAvailable(COMMAND_ACTION_A))
    private set

  var someFieldValue by mutableStateOf(someFieldDefault)
    private set

  fun onClick() {
    player.actionA()
  }

  suspend fun observe(): Nothing = player.listen { events ->
    if (events.containsAny(EVENT_B_CHANGED, EVENT_C_CHANGED, EVENT_AVAILABLE_COMMANDS_CHANGED)) {
      someFieldValue = this.someField
      isEnabled = this.isCommandAvailable(COMMAND_ACTION_A)
    }
  }
}

Чтобы реагировать на собственные Player.Events, вы можете перехватывать их с помощью Player.listen – suspend fun, которая позволяет войти в мир сопрограмм и бесконечно слушать Player.Events. Реализация различных состояний пользовательского интерфейса в Media3 позволяет разработчику не изучать Player.Events.