3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ComposeでAndroid TV(実装 - custom player controller)

Last updated at Posted at 2024-06-07

はじめに

こちらのつづきです
通常のAndroidアプリ開発において、player controllerをcustomするのは見た目以上に大変でつらいのですが、
Android TVアプリでも同一のつらみと新たなつらみがあったので書いておこうと思います
後々登場するcode blockのpathは以下のgitと紐づいています

custom player controllerのつらみ

  • Androidアプリと同一のつらみ
    • ユーザ操作がなければ、X秒後に消す
    • playerの状態と表示(seekbar・テキスト)の同期・処理分岐
  • Android TV固有のつらみ
    • focusがあたっているComponentに対してのキーバインド

つらみの解決方法

ユーザ操作がなければ、X秒後に消す

様々な方法があると思いますが、今回はcustom player controllerのvisivilityを制御するflowと自動的に消える時間を制御するflowを組み合わせることで解決しました

android_tv_temp/model/data/PlayerControllerState.kt
data class PlayerControllerState() {

    // custom player controllerのvisivilityを制御するflow
    private val _isVisibleCustomPlayerController: MutableStateFlow<Boolean> = MutableStateFlow(false)
    val isVisibleCustomPlayerController: StateFlow<Boolean> = _isVisibleCustomPlayerController.asStateFlow()
    
    // 自動的に消える時間を制御するflow
    private val autoHideControllerSeconds: MutableSharedFlow<Duration> =
        MutableSharedFlow(extraBufferCapacity = 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)

    // 自動的に消える処理を購読する
    // Flow#debounce()を使って、自動消去までの時間を待たせています
    @OptIn(FlowPreview::class)
    suspend fun observeAutoHideController() =
        autoHideControllerSeconds.debounce { it }.collect { _isVisibleCustomPlayerController.update { false } }

    // custom player controllerを表示して、自動的に消える時間を更新する
    // このmethodをすべてのユーザ操作から呼び出すことで、自動消去の時間を更新しています
    fun showCustomPlayerController() {
        _isVisibleCustomPlayerController.update { true }
        autoHideControllerSeconds.tryEmit(4.seconds)
    }

    // ユーザ操作の一例として、再生一時停止toggle method
    fun onClickTogglePlay() {
        showCustomPlayerController()
        // 再生一時停止toggleの処理が続く
    }
}
android_tv_temp/ui/screen/videoplayer/VideoPlayerScreen.kt
@Composable
fun CustomPlayerController(
    uiState: VideoPlayerScreenUiState,
) {
    val isVisible by uiState.playerControllerState.isVisibleCustomPlayerController.collectAsState()

    LaunchedEffect(Unit) {
        // ViewModelでobserveするか迷いましたが、ライフサイクルと依存を考えた時にComposableの方が良いと判断しました
        uiState.playerControllerState.observeAutoHideController()
    }
    
    // アニメーションさせて表示/非表示させてます
    AnimatedVisibility(
        visible = isVisible,
        enter = fadeIn(),
        exit = fadeOut(),
    ) {
        // ControllerのComponent群
    }
}

Playerの状態と表示(seekbar・テキスト)の同期・処理分岐

今回Playerから購読したい状態は以下です

  • 再生状態 -> 色々なものに使う(現在の再生位置の更新可否、ユーザー操作時の再生・停止命令分岐など)
  • 動画の長さ -> 表示、seekbar
  • 現在の再生位置 -> 表示、seekbar

再生状態

Listenerで取得して、flowに流します

android_tv_temp/ui/screen/videoplayer/VideoPlayerViewModel.kt
@HiltViewModel
class VideoPlayerViewModel @Inject constructor(
    val player: ExoPlayer,
) : ViewModel() {
    private val _isPlaying: MutableSharedFlow<Boolean> =
        MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
    private val isPlaying: StateFlow<Boolean> = _isPlaying.stateIn(
        scope = viewModelScope,
        started = SharingStarted.Lazily,
        initialValue = false
    )
    private val playerListener = PlayerListener()

    init {
        player.addListener(playerListener)
    }

    override fun onCleared() {
        player.removeListener(playerListener)
        super.onCleared()
    }

    private inner class PlayerListener : Player.Listener {
        override fun onIsPlayingChanged(isPlaying: Boolean) {
            _isPlaying.tryEmit(isPlaying)
        }
    }
}

動画の長さ

Listenerで取得できるタイミングになったら取得して、flowに流します
今回は面倒でflowを選択しましたが、1度しか流れないので、遅延取得する他の仕組みの方がいいかもしれないです

android_tv_temp/ui/screen/videoplayer/VideoPlayerViewModel.kt
@HiltViewModel
class VideoPlayerViewModel @Inject constructor(
    val player: ExoPlayer,
) : ViewModel() {
    private val duration: MutableStateFlow<Long> = MutableStateFlow(0L)
    private val playerListener = PlayerListener()

    init {
        player.addListener(playerListener)
    }

    override fun onCleared() {
        player.removeListener(playerListener)
        super.onCleared()
    }

    private inner class PlayerListener : Player.Listener {
        override fun onPlaybackStateChanged(playbackState: Int) {
            when (playbackState) {
                Player.STATE_READY -> {
                    viewModelScope.launch {
                        duration.value = player.duration
                    }
                }

                else -> {
                }
            }
        }
    }
}

現在の再生位置

再生中は毎秒ループさせて、Playerから現在の再生位置を取得しています

android_tv_temp/ui/screen/videoplayer/VideoPlayerViewModel.kt
@HiltViewModel
class VideoPlayerViewModel @Inject constructor(
    val player: ExoPlayer,
) : ViewModel() {
    // while loop を使うため、SharedFlowでextraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST にしています
    private val _isPlaying: MutableSharedFlow<Boolean> =
        MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
    private val isPlaying: StateFlow<Boolean> = _isPlaying.stateIn(
        scope = viewModelScope,
        started = SharingStarted.Lazily,
        initialValue = false
    )
    private val currentPosition: MutableStateFlow<Long> = MutableStateFlow(0L)
    private val playerListener = PlayerListener()

    init {
        player.addListener(playerListener)

        isPlaying.onEach {
            if (it.not()) return@onEach
            do {
                currentPosition.value = player.currentPosition
                delay(1_000L)
            } while (isPlaying.value)
        }.launchIn(viewModelScope)

    }
    
    override fun onCleared() {
        player.removeListener(playerListener)
        super.onCleared()
    }
}

focusがあたっているComponentに対してのキーバインド

Modifier#onKeyEvent()でKeyEventをハンドリングができます
そのまま使うと相当使いにくいので、目的用途のために拡張関数を作った方がよさそうです
ちなみに、ほぼGoogleが公開しているこちらのサンプルのままです

android_tv_temp/ui/extension/CustomModifier.kt
fun Modifier.onCustomKeyEvent(
    onLeft: (() -> Unit)? = null,
    onRight: (() -> Unit)? = null,
    onUp: (() -> Unit)? = null,
    onDown: (() -> Unit)? = null,
    onEnter: (() -> Unit)? = null
) = onKeyEvent {
    if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
        when (it.nativeKeyEvent.keyCode) {
            KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
                onLeft?.invoke()
                return@onKeyEvent true
            }

            KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
                onRight?.invoke()
                return@onKeyEvent true
            }

            KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP -> {
                onUp?.invoke()
                return@onKeyEvent true
            }

            KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN -> {
                onDown?.invoke()
                return@onKeyEvent true
            }

            KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
                onEnter?.invoke()
                return@onKeyEvent true
            }
        }
    }
    false
}

今回はfocusをあてることができるComponentを、

  • 画面全体のBox
  • Slider(seekbar)

としました、基本同じなので、より簡単な画面全体のBoxで確認します

画面全体のBox

focusを当てられるようにして、拡張関数を用いて各ボタンの処理を追加するだけです

android_tv_temp/ui/screen/videoplayer/VideoPlayerScreen.kt
@Composable
fun VideoPlayerScreen(
    uiState: VideoPlayerScreenUiState,
) {
    Box(
        modifier = Modifier
            .focusable()
            .onCustomKeyEvent(
                onLeft = { uiState.playerControllerState.onClickSeek(-10.seconds.inWholeMilliseconds) },
                onRight = { uiState.playerControllerState.onClickSeek(10.seconds.inWholeMilliseconds) },
                onEnter = {
                    coroutineScope.launch {
                        uiState.playerControllerState.onClickTogglePlay()
                    }
                },
                onDown = {
                    uiState.playerControllerState.showCustomPlayerController()
                }
            )
    ) {
        // VideoPlayerScreenのComponent群
    }
}

つづくかも?

動画を再生するAndroid TVアプリとして、必要最低限の機能は出来てきました🥳
後はログイン機能・検索機能・カルーセル以外のUIを作りたいと考えていますが、いつになることやら・・
補足ですが、今回の実装は以下commitにまとまっています
省略したコードや細かい実装も確認できますので、興味があれば見てみてください~

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?