본문 바로가기

Movie

Movie 프로젝트 VideoPlayer Controller

Youtube

글에서 지칭하는 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 로 구현했다.

 

 

https://jik3410.tistory.com/9

 

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 부분만 다뤄보려 한다.

 

 

코드적인 부분에 들어가기에 앞서 실제 구현된 화면을 보자.

VideoPlayerController

 

 

글의 전반적인 진행 순서는 아래와 같다.

 

  1. Controller 를 나타내는 상태를 어떻게 정의했을까?
    - 번외) 불필요한 Recomposition 은 Skip 하자.
  2. Controller UI 를 어떻게 구현했을까?
  3. 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 이 존재한다.

  1. 재생 시간/총 시간
  2. 재생을 나타내는 Slider
  3. 버퍼 정도를 나타내는 Slider

빨간 영역이 재생의 정도, 회색 영역이 버퍼의 정도를 나타낸다.

 

 

여기서 주의 깊게 볼만한 내용은 2가지이다.

  1. Slider 흐름
  2. 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가지였다.

  1. 터치에 따른 Visible 처리
  2. 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