Android Media3 ExoPlayer: 고성능 동영상 플레이어 구현

Android

ExoPlayerMedia3Android동영상Jetpack Compose

이 글은 누구를 위한 것인가

  • Android 앱에서 HLS/DASH 스트리밍을 구현하려는 팀
  • ExoPlayer를 Jetpack Compose와 통합하려는 개발자
  • PIP(Picture-in-Picture) 모드를 구현하려는 팀

들어가며

Android Media3는 ExoPlayer를 통합한 최신 미디어 라이브러리다. HLS/DASH/RTSP 스트리밍, 자막, 여러 오디오 트랙, DRM을 지원한다. PlayerView로 기본 UI를 제공하고, Jetpack Compose와도 통합된다.

이 글은 bluefoxdev.kr의 Android Media3 ExoPlayer 가이드 를 참고하여 작성했습니다.


1. Media3 아키텍처

[Media3 구성]
  ExoPlayer: 핵심 플레이어 엔진
  MediaSession: 시스템 미디어 컨트롤 연동
  MediaController: 원격 제어
  PlayerView/PlayerControlView: 기본 UI

[지원 포맷]
  스트리밍: HLS, DASH, RTSP, SmoothStreaming
  로컬: MP4, MKV, WebM, MP3, AAC
  DRM: Widevine, PlayReady, ClearKey

[성능 최적화]
  PreCachingDataSource: 다음 항목 미리 캐싱
  DownloadManager: 오프라인 다운로드
  LoadControl: 버퍼 크기 설정
  TrackSelector: 화질/오디오 트랙 선택

[PIP 모드]
  enterPictureInPictureMode()
  PlayerView의 useController = false (PIP 중)
  PictureInPictureModeChangedInfo로 상태 감지

2. ExoPlayer Compose 구현

// build.gradle.kts
// implementation("androidx.media3:media3-exoplayer:1.3.0")
// implementation("androidx.media3:media3-ui:1.3.0")
// implementation("androidx.media3:media3-exoplayer-hls:1.3.0")
// implementation("androidx.media3:media3-exoplayer-dash:1.3.0")

import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import android.content.Context

@Composable
fun VideoPlayer(
    videoUrl: String,
    modifier: Modifier = Modifier
) {
    val context = androidx.compose.ui.platform.LocalContext.current
    val exoPlayer = remember { buildExoPlayer(context) }

    LaunchedEffect(videoUrl) {
        val mediaItem = MediaItem.Builder()
            .setUri(videoUrl)
            .build()
        exoPlayer.setMediaItem(mediaItem)
        exoPlayer.prepare()
        exoPlayer.playWhenReady = true
    }

    AndroidView(
        factory = { ctx ->
            PlayerView(ctx).apply {
                player = exoPlayer
                useController = true
            }
        },
        modifier = modifier
    )

    DisposableEffect(Unit) {
        onDispose {
            exoPlayer.release()
        }
    }
}

private fun buildExoPlayer(context: Context): ExoPlayer {
    return ExoPlayer.Builder(context)
        .build()
        .apply {
            // 재생 완료 후 반복
            repeatMode = Player.REPEAT_MODE_OFF
            // 볼륨
            volume = 1f
        }
}

// 재생 목록 관리
class VideoPlaylistManager(private val player: ExoPlayer) {
    fun setPlaylist(urls: List<String>) {
        val items = urls.map { MediaItem.fromUri(it) }
        player.setMediaItems(items)
        player.prepare()
    }

    fun skipToNext() = player.seekToNextMediaItem()
    fun skipToPrevious() = player.seekToPreviousMediaItem()
    fun seekTo(positionMs: Long) = player.seekTo(positionMs)
}

// PIP 모드 Activity
import android.app.PictureInPictureParams
import android.os.Build
import android.util.Rational
import androidx.activity.ComponentActivity

class VideoActivity : ComponentActivity() {
    private var player: ExoPlayer? = null

    override fun onUserLeaveHint() {
        super.onUserLeaveHint()
        enterPipIfSupported()
    }

    private fun enterPipIfSupported() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val params = PictureInPictureParams.Builder()
                .setAspectRatio(Rational(16, 9))
                .build()
            enterPictureInPictureMode(params)
        }
    }

    override fun onPictureInPictureModeChanged(
        isInPictureInPictureMode: Boolean,
        newConfig: android.content.res.Configuration
    ) {
        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
        // PIP 모드에서 컨트롤 숨기기
    }
}

마무리

Media3 ExoPlayer의 핵심은 MediaItem 빌더 패턴이다. URL, DRM 설정, 자막을 하나의 MediaItem에 구성하고 setMediaItem()으로 플레이어에 전달한다. Compose에서는 AndroidViewPlayerView를 래핑하고, DisposableEffect에서 반드시 player.release()를 호출해 리소스를 해제한다.