글에서 지칭하는 VideoPlayer Controller 란 뭔가 하면 위 사진과 같이 재생, 앞으로 가기 등 Video 의 Control 을 책임지는 요소이다.
https://github.com/jhg3410/Movie
GitHub - jhg3410/Movie
Contribute to jhg3410/Movie development by creating an account on GitHub.
github.com
lib-videoplayer 모듈에 관련된 모든 코드 존재.
모든 내용을 하나의 포스트로 담기엔 무리가 있기에 중요하고 신경 쓸만한 부분들만 남겼다.
실제 코드와 함께 읽어야 이해하기에 이롭다.
Movie 프로젝트를 시작했던 큰 이유 중 하나였던 Custom Controller 를 구현한 과정을 담았다.
참고로 모두 Compose 로 구현했다.
Movie 프로젝트 VideoPlayer
v0.2.0 기준 Release v0.2.0 · jhg3410/Movie What's Changed double click 방지, 화면 전환 애니메이션 구현 by @jhg3410 in #12 Detail UI 수정 (Cast 추가), Pagination 버그 수정 by @jhg3410 in #13 Detail UI 수정 (Teaser 영상 추가) by
jik3410.tistory.com
Player 관련된 내용은 윗 글에 존재하니 제외하고 Controller 부분만 다뤄보려 한다.
코드적인 부분에 들어가기에 앞서 실제 구현된 화면을 보자.
글의 전반적인 진행 순서는 아래와 같다.
- Controller 를 나타내는 상태를 어떻게 정의했을까?
- 번외) 불필요한 Recomposition 은 Skip 하자. - Controller UI 를 어떻게 구현했을까?
- Controller Visible 를 어떤 식으로 처리했을까?
Controller 를 나타내는 상태를 어떻게 정의했을까?
Controller 에도 Player 와 마찬가지로 상태가 존재한다.
Controller 에 나타날 수 있는 상태들은 아래와 같다.
sealed interface VideoPlayerControllerState {
object INITIAL : VideoPlayerControllerState
object LOADING : VideoPlayerControllerState
object PLAYING : VideoPlayerControllerState
object PAUSED : VideoPlayerControllerState
object ENDED : VideoPlayerControllerState
class ERROR(var errorMessage: String = ControllerErrorType.UNKNOWN_ERROR.message) :
VideoPlayerControllerState
}
상태가 변함에 따라 어떻게 Controller UI 가 변할까? (초기값인 INITIAL 은 제외.)
나열된 순서대로 ScreenShot 을 보여드리자면 아래와 같다. (Loading 은 CircularProgressIndicator)
그렇다면 Controller 의 상태는 어떻게 정할까?
internal fun getControllerState(
isPlaying: Boolean,
playbackState: Int
): VideoPlayerControllerState {
if (isPlaying) {
return VideoPlayerControllerState.PLAYING
}
if (playbackState == ExoPlayer.STATE_IDLE) {
return VideoPlayerControllerState.ERROR()
}
if (playbackState == ExoPlayer.STATE_BUFFERING) {
return VideoPlayerControllerState.LOADING
}
if (playbackState == ExoPlayer.STATE_READY) {
return VideoPlayerControllerState.PAUSED
}
if (playbackState == ExoPlayer.STATE_ENDED) {
return VideoPlayerControllerState.ENDED
}
return VideoPlayerControllerState.ERROR()
}
ExoPlayer 에서 지원하는 isPlaying, playbackState 변수를 통해서 지정.
val stateChangedListener = stateChangedListener { changedPlayer ->
isPlaying = changedPlayer.isPlaying
currentPosition = changedPlayer.currentPosition
controllerState = getControllerState(
isPlaying = isPlaying,
playbackState = changedPlayer.playbackState
)
}
ExoPlayer 의 onEvents(Called when one or more player states changed) Listener 를 활용해서 상태가 변할 때마다 controllerState 를 입맛대로 지정해 줬다.
ControllerState 를 어디서 사용할까?
먼저 default controller 를 제거하기 위해
PlayerView(context).apply {
this.useController = false
기존의 PlayerView(VideoPlayerScreen) 에 위 옵션을 넣어줘야 한다.
is VideoPlayerState.CanPlay -> {
val moviePlayer = player ?: return
VideoPlayerScreen(
player = moviePlayer,
onScreenClick = { controllerVisible = !controllerVisible }
)
VideoPlayerController(
modifier = Modifier.fillMaxSize(),
visible = controllerVisible,
controllerState = controllerState.apply {
if (this is VideoPlayerControllerState.ERROR) {
this.setErrorMessage(errorCode = moviePlayer.playerError?.errorCode)
}
},
onRefresh = {
moviePlayer.prepare()
moviePlayer.play()
},
onPlay = moviePlayer::play,
onPause = moviePlayer::pause,
onReplay = moviePlayer::seekTo,
onForward = moviePlayer::seekTo,
onBackward = moviePlayer::seekTo,
getCurrentPosition = moviePlayer::getCurrentPosition,
currentPosition = currentPosition,
duration = moviePlayer.contentDuration,
bufferedPercentage = moviePlayer.bufferedPercentage,
onSlide = moviePlayer::seekTo
)
}
이전 글에선 Player 가 CanPlay 상태일 때 Screen 만 보여줬다면 이젠 Controller 도 함께 보여줘야 한다.
해당 Controller Composable 에 앞서 구한 controllerState 를 인자로 넘겨준다.
번외) 불필요한 Recomposition 은 Skip 하자.
Controller Composable 의 인자로는 controllerState 와 다양한 함수들의 참조값이 들어간다.
왜 참조값이 들어갈까? moviePlayer::play 대신에 { moviePlayer.play() } 를 사용하면 안 될까?
recomposition 의 차이가 존재한다
layout inspector 로 확인해 보자.
왼쪽이 람다를 사용했을 때, 오른쪽이 참조를 사용했을 때이다.
오른쪽은 CenterController Row 내부의 모든 Composable 이 Recomposition Skip 되었다.
HWUI 렌더링 프로파일을 통해 성능 차이를 알아보자.
왼쪽이 람다를 사용했을 때, 오른쪽이 참조를 사용했을 때이다.
왜? 이런 차이가 있을까?
CenterController 에서 Log 를 찍어보자.
{ moviePlayer.play() } 를 인자로 넘겼을 땐 항상 새로운 객체가 넘어온다.
moviePlayer::play 로 넘기면 항상 같은 값이 넘어온다.
람다로 넘겨줄 때 항상 새로운 객체가 넘어가는 이유는 내부의 moviePlayer(ExoPlayer) 가 Unstable 이기 때문이다.
그렇기에 상위 Composable 에서 recomposition 이 발생하면 항상 새로운 람다 함수를 인자로 넘겨준다.
→ Recomposition 유발
참조(::) 값을 사용하면 상위 Composable 에서 recomposition 이 발생해도 항상 같은 참조값을 넘겨준다.
이를 통해 moviePlayer(ExoPlayer) 의 play 함수 자체를 호출
→ Recomposition Skip
Controller UI 를 어떻게 구현했을까?
두 가지 영역이 존재한다.
해당 영역들이 아래 코드에서 CenterController, BottomController 두 가지로 표현된다.
ControllerState가 Error 일 때는 ErrorScreen 만 화면에 나타낸다.
Controller UI 최상위 코드
@Composable
fun VideoPlayerController(
modifier: Modifier = Modifier,
visible: Boolean,
controllerState: VideoPlayerControllerState,
onRefresh: () -> Unit,
onPlay: () -> Unit,
onPause: () -> Unit,
onReplay: (Long) -> Unit,
onForward: (Long) -> Unit,
onBackward: (Long) -> Unit,
getCurrentPosition: () -> Long,
currentPosition: Long,
duration: Long,
bufferedPercentage: Int,
onSlide: (Long) -> Unit
) {
AnimatedVisibility(
modifier = modifier,
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) {
if (controllerState is VideoPlayerControllerState.ERROR) {
ErrorScreen(
errorMessage = controllerState.errorMessage,
onRefresh = onRefresh
)
return@AnimatedVisibility
}
Box(
modifier = Modifier.background(color = Color.Black.copy(alpha = 0.5f))
) {
CenterController(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(),
controllerState = controllerState,
onPlay = onPlay,
onPause = onPause,
onReplay = onReplay,
onForward = { onForward(getCurrentPosition() + MOVING_OFFSET) },
onBackward = { onBackward(getCurrentPosition() - MOVING_OFFSET) }
)
BottomController(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(),
currentPosition = currentPosition,
duration = duration,
bufferedPercentage = bufferedPercentage,
onSlide = onSlide
)
}
}
}
CenterController
보다시피 Row 안에 3개의 Composable 이 존재한다.
1. 뒤로 가기
2. 상태에 따른 아이콘
3. 앞으로 가기
ControllerState 에 따라 2번 아이콘만 항상 변한다.
when (controllerState) {
is VideoPlayerControllerState.LOADING -> {
ControllerLoadingWheel()
}
is VideoPlayerControllerState.PLAYING -> {
ControllerPauseIcon {
onPause()
}
}
is VideoPlayerControllerState.PAUSED -> {
ControllerPlayIcon {
onPlay()
}
}
is VideoPlayerControllerState.ENDED -> {
ControllerReplayIcon {
onReplay(0L)
}
}
else -> Unit
}
Icon Composable 의 인자(람다)에는 클릭했을 때의 동작(함수)들이 들어간다.
해당 함수들은 최상위 Composable(VideoPlayerController) 의 인자들이다.
전체 코드
@Composable
fun CenterController(
modifier: Modifier = Modifier,
controllerState: VideoPlayerControllerState,
onPlay: () -> Unit,
onPause: () -> Unit,
onReplay: (Long) -> Unit,
onForward: () -> Unit,
onBackward: () -> Unit
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { onBackward() }
) {
Icon(
modifier = Modifier.size(iconSize),
imageVector = Backward5,
contentDescription = "backward",
tint = Color.White,
)
}
when (controllerState) {
is VideoPlayerControllerState.LOADING -> {
ControllerLoadingWheel()
}
is VideoPlayerControllerState.PLAYING -> {
ControllerPauseIcon {
onPause()
}
}
is VideoPlayerControllerState.PAUSED -> {
ControllerPlayIcon {
onPlay()
}
}
is VideoPlayerControllerState.ENDED -> {
ControllerReplayIcon {
onReplay(0L)
}
}
else -> Unit
}
IconButton(
onClick = { onForward() }
) {
Icon(
modifier = Modifier.size(iconSize),
imageVector = Forward5,
contentDescription = "Forward",
tint = Color.White,
)
}
}
}
BottomController
마찬가지로 3가지 Composable 이 존재한다.
- 재생 시간/총 시간
- 재생을 나타내는 Slider
- 버퍼 정도를 나타내는 Slider
빨간 영역이 재생의 정도, 회색 영역이 버퍼의 정도를 나타낸다.
여기서 주의 깊게 볼만한 내용은 2가지이다.
- Slider 흐름
- Slider UI
Slider 흐름
재생을 나타내는 Slider 는 자연스럽게 흘러야 한다. → Slider 의 value 값이 계속해서 변하면서 recomposition 이 진행되어야 한다.
if (isPlaying) {
LaunchedEffect(key1 = Unit) {
while (player != null) {
currentPosition = player?.currentPosition ?: 0L
delay(1.seconds / 30)
}
}
}
위와 같이 Slider 의 value 로 전달될 currentPosition 를 계속해서 업데이트해서 recomposition 을 유발했다. 물론 State 변수이다.
Slider UI
Material3 의 Slider Composable 을 별 다른 옵션 없이 사용하면 아래와 같이 Vertical 의 padding 이 많이 들어간 것을 볼 수 있다.
하지만 내가 원하는 UI 는 아래와 같이 padding 이 존재하지 않아야 한다.
해당 padding 은 왜 존재할까? 에 대한 물음을 가지고 내부 코드로 들어가면 아래와 같은 코드가 나온다.
Layout(
{
Box(modifier = Modifier.layoutId(SliderComponents.THUMB)) { thumb(sliderPositions) }
Box(modifier = Modifier.layoutId(SliderComponents.TRACK)) { track(sliderPositions) }
},
modifier = modifier
.minimumInteractiveComponentSize()
.requiredSizeIn(
minWidth = SliderTokens.HandleWidth,
minHeight = SliderTokens.HandleHeight
)
material3/SliderKt.class/SliderImpl
위 코드에서 봐야 할 부분은 아래 함수이다.
.minimumInteractiveComponentSize()
Reserves at least 48.dp in size to disambiguate touch interactions if the element would measure smaller.
해당 함수에 대한 주석 중 일부이다.
클릭/터치 영역이 최소 48 * 48 은 보장받아야 한다는 뜻이다. (Material 에서 권장)
그렇기에 vertical 영역이 48 dp 만큼 padding 이 커진 것이다.
하지만 이 영역이 터치 영역에 영향을 주진 않고, layout 에만 영향을 준다.
그렇기에 해당 옵션을 무시해도 사용엔 문제가 없다 판단하고 아래 코드를 추가했다.
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
Slider(
~~
)
}
위 코드를 통해 LocalProvider 의 내부 Composable 들은 minimumInteractiveComponentSize 옵션이 무시된다.
전체 코드
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomController(
modifier: Modifier = Modifier,
duration: Long,
currentPosition: Long,
bufferedPercentage: Int,
onSlide: (Long) -> Unit
) {
Column(modifier = modifier.padding(bottom = 4.dp)) {
Text(
modifier = Modifier.padding(start = 16.dp),
text = currentPosition.toFormattedMinutesAndSecondsFromMilliseconds() + "/" +
duration.toFormattedMinutesAndSecondsFromMilliseconds(),
style = MaterialTheme.typography.labelMedium,
color = Color.White
)
Spacer(modifier = Modifier.height(4.dp))
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
Box(modifier = Modifier.fillMaxWidth()) {
Slider(
value = bufferedPercentage.toFloat(),
enabled = false,
onValueChange = {},
valueRange = 0f..100f,
colors = SliderDefaults.colors(
disabledThumbColor = Color.Transparent,
disabledActiveTrackColor = Color.Gray
)
)
Slider(
value = currentPosition.toFloat(),
onValueChange = { onSlide(it.toLong()) },
valueRange = 0f..duration.coerceAtLeast(0).toFloat(),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.Gray.copy(alpha = 0.5f),
)
)
}
}
}
}
Controller Visible 를 어떤 식으로 처리했을까?
요구사항은 2가지였다.
- 터치에 따른 Visible 처리
- Visible 상태에서 재생 중이라면 2초 뒤에 자동으로 Invisible
현재 Visible 상태를 나타낼 변수
var controllerVisible by remember { mutableStateOf(true) }
Controller Composable 에서 controllerVisible 에 따라 AnimatedVisibility 처리
VideoPlayerController(
modifier = Modifier.fillMaxSize(),
visible = controllerVisible
Screen 을 클릭할 때마다 controller 의 visible 은 변화 (visible → Invisible , Invisible → visible)
VideoPlayerScreen(
player = moviePlayer,
onScreenClick = { controllerVisible = !controllerVisible }
)
ControllerState 가 PLAYING(재생 중)이고, controller 가 visible 상태일 때는 2초 뒤 invisible 처리
LaunchedEffect(key1 = controllerState, key2 = controllerVisible) {
if (controllerState == VideoPlayerControllerState.PLAYING && controllerVisible) {
delay(VISIBLE_DURATION)
controllerVisible = false
}
}
key: State
상태가 PLAYING 으로 변하고 visible 상태라면 2초 뒤 invisible
상태가 PLAYING 으로 변한다?
ex). 재생 중일 때 앞으로 가기, 재생 중일 때 뒤로 가기, paused 상태에서 재생 버튼 클릭
재생 중일 때 앞으로 가기와 뒤로 가기는 실제로 상태가 PLAYING -> LOADING -> PLAYING 으로 변하게 된다.
Key: controllerVisible
클릭을 해서 visible 이 true 로 변해도 재생 중이라면 2초 뒤 자동으로 Invisible
도움을 많이 받았습니다! 🙇♂️
https://betterprogramming.pub/custom-exoplayer-controls-in-jetpack-compose-c4089def0106
Custom ExoPlayer Controls in Jetpack Compose
Add a custom controls UI overlay for ExoPlayer
betterprogramming.pub
지금까지 개발하면서 가장 오래 걸리고 고민한 기능이지 않을까?
그만큼 의미 있는 시간들이였고 많이 얻었다. 또, 만들었을 때의 성취감이란,,,,
위의 기능뿐만 아니라 추가로 넣을 수 있는 부가적인 기능들이 많다. (전체화면, PIP 모드, 음소거 등)
모두 재밌을 요소라 다른 feature 구현을 끝내면 해보려 한다.
'Movie' 카테고리의 다른 글
Movie 프로젝트 VideoPlayer (1) | 2023.08.24 |
---|---|
Movie 프로젝트 edge-to-edge (0) | 2023.07.16 |
Movie 프로젝트 Pagination (0) | 2023.07.15 |
Movie 프로젝트 네트워크 통신 (0) | 2023.07.15 |
Movie 프로젝트 구조 (0) | 2023.07.15 |