ComposeでAndroid TV（実装 - custom player controller）

# はじめに

https://qiita.com/a7ther/items/61b92a4759caddf54a1f

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

https://github.com/a7ther/andoroid-tv-temp

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

# つらみの解決方法
## ユーザ操作がなければ、X秒後に消す

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

```kotlin: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の処理が続く
    }
}
```
```kotlin: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に流します
```kotlin: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を選択しましたが、１度しか流れないので、遅延取得する他の仕組みの方がいいかもしれないです
```kotlin: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から現在の再生位置を取得しています
```kotlin: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が公開しているこちらのサンプルのままです

https://github.com/android/tv-samples/blob/149466c5ec488ab22b92763bc832ba2d49f9cb9f/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/ModifierUtils.kt#L75

```kotlin: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を当てられるようにして、拡張関数を用いて各ボタンの処理を追加するだけです

```kotlin: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にまとまっています
省略したコードや細かい実装も確認できますので、興味があれば見てみてください～

https://github.com/a7ther/andoroid-tv-temp/commit/5038bd2fdd3d164e3a388b46b4c24c9ccb6a9111