본문으로 바로가기
반응형

ExoPlayer는 다양한 방법으로 재생을 지원합니다. 단말에 저장하고 있는 동영상 파일이라던가, 앱의 resource로 가지고 있는 파일, 서버에 존재하여 uri로 (http로 다운받아) 재생하기, 게다가 DRM 걸린파일까지도 재생을 지원합니다.

유튜브에서 사용하는 player인 만큼 그만큼 확장성과, 범용성, 다양한 기능까지 제공합니다. 기본적인 사용법은 codelap을 통해서 익힐 수 있습니다.[1]

또한 재생가능한 media들 http를 이용하는 방법, 기본적인 Controller의 사용방법들은 공식문서에서 사용법을 확인할 수 있습니다.[2]

문서가 다양하게 준비 되어있는것 처럼 보이지만 실제로 사용함에 있어 공식문서만 가지고는 구현하기가 쉽지 않습니다. code lap 역시 기본 사용법만 제공하기에 여기서는 http에 다양한 조건에 따라 ProgressiveMediaSource를 이용하여, 다운로드를 진행하면서 media를 재생하는 방법에 대해서 알아봅니다. (streaming은 아닙니다.)

이 예제는 ExoPlayer v2.16.1 을 이용하였습니다.

 

HTTP GET 방식으로 media 재생

HTTP 관련 코드를 작성하기에 앞서 Exo Player를 간단하게 생성합니다.

val exoPlayer: ExoPlayer

init {
    //exoPlayer 기본값 설정.
    exoPlayer = ExoPlayer.Builder(context)
                     .setLoadControl(DefaultLoadControl())
                     .setTrackSelector(DefaultTrackSelector(context))
                     .build().apply {
                playWhenReady = true //소스가 준비되면 바로 재생
                repeatMode = Player.REPEAT_MODE_ONE //무한 반복 재생
                seekTo(currentWindow, playbackPosition)         
            }
}

HTTP 방식을 사용하기 위해서는 DefaultDataSourceFactory를 이용합니다. 아래와 같이 DataSource.Factory를 생성하는 함수를 하나 만듭니다.

private fun createSourceFactory(): DataSource.Factory {
    val defaultDataSourceFactory = DefaultHttpDataSource
                                   .Factory()
                                   .setUserAgent(Util.getUserAgent(context, "앱이름"))
    return defaultDataSourceFactory
}

UserAgent 값은 보통 strings.xml에 선언해 놓은 R.string.app_name 값을 가져와서 사용합니다.

이제 DataSourceFactory를 이용해서 데이터를 로드하는 코드를 아래와 같이 작성합니다.

// url은 "http://주소/파일이름.mp4" 형태로 사용
fun play(url: String) {
    val mediaItem = MediaItem.Builder().setUri(Uri.parse(url)).build()
    val mediaSource = ProgressiveMediaSource.Factory(createSourceFactory())
            .createMediaSource(mediaItem)

    exoPlayer.apply {
            setMediaSource(mediaSource, true)
            playWhenReady = true
            prepare()
        }
}

서버에 위치한 http 주소에 접속하여 다운로드를 받으면 player가 재생을 시작합니다. 아주 간단하죠?

Http header를 추가.

사실 서버에서 미디어를 다운받는 경우 인증값이나 추가 정보를 http header에 포함해야 하는경우도 있습니다. Header에 정보를 추가 하기 위해 createSourceFactory() 함수를 아래와 같이 수정합니다.

private fun createSourceFactory(): DataSource.Factory {
    val defaultDataSourceFactory = DefaultHttpDataSource
                                   .Factory()
                                   .setUserAgent(Util.getUserAgent(context, "앱이름"))    
     // 헤더 정보를 추가                              
     val dataSourceFactory = ResolvingDataSource
       		.Factory(defaultDataSourceFactory) { dataSpec ->    
            dataSpec.withAdditionalHeaders(mutableMapOf<String, String>().apply {
                put("user-id", "user id 값")
                put("access-token", "인증 토큰 값")
                put("auth-id", "인증 아이디 값")
            })
            
    return dataSourceFactory
}

ResolvingDataSource를 는 데이터를 전송할때 부가적인 정보추가를 지원하는 DataSource 입니다.

ResolvingDataSource의 Factory를 생성할때 DefaultHttpDataSource를 인자로 넣습니다. 즉, DataSource 자체가 Decorate pattern으로 이루어져 계속해서 부가적인 동작을 추가할 수 있습니다.

이용하여 기본  예시로 세개의 header를 붙였습니다. 필요한 형태로 key / value를 추가할 수 있습니다.

Cache의 사용

기본적으로 미디어는 서버 어딘가에 위치합니다. 따라서 이를 다운받아 재생하지만, 매번 다운로드 받아 재생하기에는 네트워크 부담이 큽니다. 다운로드에 대한 시간적인 delay도 발생하지만, 네트워크 트래픽도 무시할수 없습니다.

더군다나, 과금망을 사용한다면 더욱 그렇겠죠?

따라서 DataSource중에 Cache를 지원할수 있도록 CacheDataSource를 사용하도록 합니다. 이것 역시 Decorate pattern으로 동작합니다. 다만 이에 앞서 cache를 어떤방식으로 어떻게 사용할지에 대한 정의가 필요합니다. 

cache는 exo player에서 공용으로 사용되어야 하므로 singleton으로 생성해야 합니다. 여기서는 Hilt로 주입하기 위해 아래와 같이 모듈을 추가합니다.

@InstallIn(SingletonComponent::class)
@Module
object ProfileModule {
    @Provides
    @Singleton
    fun providesSimpleCache(application: Application): Cache {
        // cache를 저장할 폴더명
        val cacheFoler = ".playerCache"
        
        // 외부 storage 영역의 지정된 앱 data 폴더에 위치
        val cachePath = (application.applicationContext).getExternalFilesDir(null)
        
        //LRU 방식으로 30MB 사용
        val evictor = LeastRecentlyUsedCacheEvictor(30*1024*1024) 
        
        return SimpleCache(
                File(cachePath, cacheFoler),
                LeastRecentlyUsedCacheEvictor(evictor),
                StandaloneDatabaseProvider(application.applicationContext))        
    }
}

예제에서는 캐쉬 방식은 LRU 이고, 용량은 30MB를 지정했습니다. 이외에도 NoOpCacheEvictor를 사용하여 직접 cache의 유지 동작을 관리해줄 수도 있습니다.[3] 

Cache는 singleton으로 만들어 사용해야 합니다. 따라서 사용처에서 해당 cache를 inject해서 사용하면 singleton으로 생성되어 동일 Cache를 여러 exo player에서 같이 사용할수 있습니다.

다시 createSourceFactory()를 아래와 같이 바꿉니다. (기본 구성은 동일하나, 마지막 return하기 전에 CacheDataSource factory를 반환합니다.

반응형
@Inject
lateinit var exoPlayerCache: Cache
...

private fun createSourceFactory(): DataSource.Factory {
    val defaultDataSourceFactory = DefaultHttpDataSource
                                   .Factory()
                                   .setUserAgent(
                                     Util.getUserAgent(context, "앱이름")
                                   )    
     val dataSourceFactory = ResolvingDataSource
       		.Factory(defaultDataSourceFactory) { dataSpec ->    
            dataSpec.withAdditionalHeaders(mutableMapOf<String, String>().apply {
                put("user-id", "user id 값")
                put("access-token", "인증 토큰 값")
                put("auth-id", "인증 아이디 값")
            })
        }
            
    // Cache 이용이 가능한 CacheDataSource 사용
    return CacheDataSource.Factory().apply {
                setCache(exoPlayerCache)
                setUpstreamDataSourceFactory(dataSourceFactory)
                }
}

이때 cache의 matching 여부는 내부에서 발행하는 고유 id로 하게 되는데, 이 경우 url을 cache key 사용하는게 더 효율적입니다. 즉 동일 url이라면 cache에 저장된 media를 재생합니다.

아래와 같이 play() 함수에서 MediaItem을 생성할때, setCustomCacheKey()를 이용합니다.

fun play(url: String) {
    val mediaItem = MediaItem.Builder()
                             .setCustomCacheKey(url) // cache key를 지정
                             .setUri(Uri.parse(url))
                             .build()
    val mediaSource = ProgressiveMediaSource.Factory(createSourceFactory())
            .createMediaSource(mediaItem)
    ...
}

여기서는 createSourceFactory()에서 CacheDataSource를 바로 반환 했지만  현재 storgage 용량을 보고 CacheDataSource를 반환할지, 그냥 ResolvingDataSource를 반환할지에 대한 조건문을 넣는것이 좋습니다.

HTTP POST 방식의 이용

만약 base url만 제공하고, post 방식으로 데이터를 요청하여, post의 body에 파일명을 넣어서 보내라는 서버 요구사항이 있다고 가정합니다. 정리하면 아래와 같습니다.

  • HTTP method: POST
  • URL: http://test.server.com/download_media
  • HTTP Body: "file=sample.mp4" 

http에 추가적인 설정을 하는것이기 때문에 ResolvingDataSource를 수정해야 합니다. 이를 생성했던 createSrouceFactory()함수에 아래와 같은 내용을 추가합니다.

private fun createSourceFactory(): DataSource.Factory {
    ...
     val dataSourceFactory = ResolvingDataSource
       		.Factory(defaultDataSourceFactory) { dataSpec ->    
            dataSpec.withAdditionalHeaders(mutableMapOf<String, String>().apply {
                put("user-id", "user id 값")
                put("access-token", "인증 토큰 값")
                put("auth-id", "인증 아이디 값")
            })
            
            // post 방식에 body를 싣어서 보내도록 수정
            val body = "file=sample.mp4"
            dataSpec.buildUpon()
                    .setHttpMethod(HTTP_METHOD_POST)
                    .setHttpBody(body.toByteArray())
                    .build()
       }
            
    ...
}

HTTP POST 방식으로 body에 json 정보 전송

보통 http body에 json 형태의 데이터를 싣어서 보냅니다. 예를 들어 아래와 같은 형태를 body로 요청해야 한다고 가정합니다.

  • Http body: { "file":"sample.mp4"}

일반적이라면 위 코드의 body에 json을 싣어서 보내면 될것 같지만 단순히 body에 json을 byte[] 로 변경해서 넣을경우 url encoding이 자동으로 발생합니다. 아마도 exo player에서 기본으로 제공하는 android http client에서 그렇게 하는것일수도 있고, exo player에서 처리하는것인지는 모르겠습니다만, 실제로 서버에서 받는 데이터는 아래와 같았습니다.

%7B%22file%22%...(생략)

물론 이렇게 보내더라도 서버에서 잘 decoding해서 정보를 읽어줄수 있는 경우라면 상관없지만, 제가 개발하고 있는 서버 side에서는 url encoding없이 보내야 했기 때문에 위 방법을 그대로 사용하기에는 문제가 있습니다. (물론 제가 방법을 못찾았을 수도 있습니다.)

이를 회피하기 위해서 아래와 같이 okHttp를 사용하도록 바꿔보겠습니다. 

먼저 gradle에 하기와 같이 okhttp를 import 합니다.[4]

implementation 'com.google.android.exoplayer:extension-okhttp:2.X.X'

그리고 아래와 같이 createSourceFactory()를 아래와 같이 수정합니다.

private fun createSourceFactory(): DataSource.Factory {
     val body = """{"file":"sample.mp4"}"""
     
     val httpClient = OkHttpClient.Builder()
             .connectTimeout(1, TimeUnit.MINUTES)
             .readTimeout(1, TimeUnit.MINUTES)
             .retryOnConnectionFailure(false)
             .addInterceptor(Interceptor { chain ->
                 val contentType = "application/json".toMediaTypeOrNull()
                 val requestBody = body.toRequestBody(contentType)
                 val request = chain.request().newBuilder()
                            .post(requestBody)
                            .addHeader("user-id", "user id 값")
                            .addHeader("access-token", "인증 토큰 값")
                            .addHeader("auth-id", "인증 아이디 값")                            
                            .build()
                    chain.proceed(request)
                })

      val dataSourceFactory = OkHttpDataSource.Factory(httpClient.build())
                .setUserAgent(Util.getUserAgent(context, Const.APP_NAME))
    
       // Cache 이용이 가능한 CacheDataSource 사용
       return CacheDataSource.Factory().apply {
                  setCache(exoPlayerCache)
                  setUpstreamDataSourceFactory(dataSourceFactory)
               }
}

post method를 사용하며, header와 body에 원하는 형태의 구문을 넣을수 있습니다.

Debugging을 위한 Log 추가

Exoplayer에서 http를 이용하기 위해서는 DataSourceFactory를 생성하여 전달해야 합니다. http의 요청에 따른 응답은 MediaSourceEventListener를 이용해야 합니다.

보다 앞서 재생, 정지, 해제, 버퍼링등의 Player의 상태에 대한 이벤트를 받기 위해서는 아래와 같이 Player 자체에 listener를 붙일 수 있습니다.

val exoPlayer: ExoPlayer

// 재생에 관련된 listener
val playerListener = object : Player.Listener {
    override fun onPlaybackStateChanged(playbackState: Int) {
        when (playbackState) {
            /**
             * The player is idle, meaning it holds only limited resources. The player must be [ ][.prepare] before it will play the media.
             */
            Player.STATE_IDLE -> { //doo something }
            
            /**
             * The player is not able to immediately play the media, but is doing work toward being able to do
             * so. This state typically occurs when the player needs to buffer more data before playback can
             * start.
             */
            Player.STATE_BUFFERING -> { //doo something }
             
            /**
             * The player is able to immediately play from its current position. The player will be playing if
             * [.getPlayWhenReady] is true, and paused otherwise.
             */
            Player.STATE_READY -> { //doo something }
  
            /** The player has finished playing the media.  */
            Player.STATE_ENDED -> { //doo something }
        }
    }
}

init {
    //exoPlayer 기본값 설정.
    exoPlayer = ExoPlayer.Builder(context)
                     .setLoadControl(DefaultLoadControl())
                     .setTrackSelector(DefaultTrackSelector(context))
                     .addListener(playerListener) //Player 상태에 대한 listener
                     .build().apply {
                ...
            }
}

만약 Http 응답에 대한 결과를 보고 싶다면 아래와 같이 mediaItem에 listener를 부착해야 합니다.

먼저 MediaSourceEventListener를 만듭니다.

private val mediaSourceListener = object : MediaSourceEventListener {
    override fun onLoadStarted(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?,
                               loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) {
        // http 응답에 대한 response를 출력
        loadEventInfo.responseHeaders.keys.forEach { key ->
            LogInfo(TAG) { "onLoadStarted() - key:$key | " +
                           "value: {${loadEventInfo.responseHeaders[key]} " }
        }
    }

    override fun onLoadCompleted(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?,
                                 loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) {}

    override fun onLoadCanceled(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?,
                                loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) {}

    override fun onLoadError(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?,
                             loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData,
			     error: IOException, wasCanceled: Boolean) {        
        val customCacheKey = exoPlayer.getMediaItemAt(0).localConfiguration?.customCacheKey
        LogError(TAG) { "onLoadError() - remove wrong cache. key:$customCacheKey" }

        // http 응답에 대한 response를 출력
        loadEventInfo.responseHeaders.keys.forEach { key ->
            LogError(TAG) { "onLoadError() - key:$key | "
                            "value: {${loadEventInfo.responseHeaders[key]} " }
        }

        // error가 발생한 경우 cache를 비워 준다. (남겨두면 계속 에러난걸 불러옴.)
        if (customCacheKey != null && exoProfileCache.keys.contains(customCacheKey)) {
            launch(Dispatchers.IO) {
                exoProfileCache.removeResource(customCacheKey)
            }
        }
    }

    override fun onUpstreamDiscarded(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId,
                                     mediaLoadData: MediaLoadData) {}

    override fun onDownstreamFormatChanged(windowIndex: Int, mediaPeriodId: MediaSource.MediaPeriodId?,
                                           mediaLoadData: MediaLoadData) {}
}

Error 발생시에는 헤더 정보를 출력하기도 하지만, cache에 넣어놓은 데이터도 삭제해야 합니다. 

만약 에러난 데이터가 cache에 들어갈 경우 해당 key로 요청되는 작업은 cache에서 실패한 정보를 꺼내다 쓰는 경우가 발생합니다. 따라서 서버 요청없이 한번 실패로 인하여 재요청시에도 계속 실패가 발생할 수 있습니다.

이 listener는 위에서 정의했던 play() 함수안의 MediaItem을 생성과 함께 추가해 줍니다.

// url은 "http://주소/파일이름.mp4" 형태로 사용
fun play(url: String) {
    val mediaItem = MediaItem.Builder().setUri(Uri.parse(url)).build()
    val mediaSource = ProgressiveMediaSource.Factory(createSourceFactory())
            .createMediaSource(mediaItem)
            // http 응답을 확인하기 위한 listener 추가
            .addEventListener(Handler(Looper.getMainLooper()), mediaSourceListener)

    exoPlayer.apply {
            setMediaSource(mediaSource, true)
            playWhenReady = true
            prepare()
            }
}

 

결론

여기서는 Exo player의 http 관련 동작을 정리했습니다. 그외에도 PlayerView를 통해서 video를 보여줄수도 있고, ExoPlayerPlayerView의 조합으로 어느정도 원하는 동영상 재생 화면을 만들어 낼수 있습니다.

이 둘의 조합은 마치 동영상에 대한 통합 솔루션급으로 play에 대한 control뿐만 아니라, 중간중간 데이터를 로딩할때 자동으로 progress를 보여주는 설정등을 비롯하여 화면의 배치나 동작에 대한 customizing 같은 막강한 기능을 제공합니다.

개인적으로는 "잘 쓰면 좋은데, 공식적으로 정리된 가이드나 예제가 부족하여 잘쓰기가 어렵다."는 느낌을 받았습니다. 장점이 많지만 내부에 숨겨진 동작을 파악하기 어렵고, 에러에 대한 동작분석이 쉽지 않았습니다. Exo Player에 문제가 있다라기 보단 사용하는 방법이 잘못되어 play에 실패하나, 이 실패 원인을 유추하기 위한 로그를 print하는것도 쉽지 않았습니다.

하지만 "Youtube에서 사용한다" 라는 전제만으로도 성능이나, 범용성에서만은 막강하다고 생각되며, 앞으로도 계속적인 버전 향상을 기대해 봅니다.

References

[1] https://developer.android.com/codelabs/exoplayer-intro?hl=ko#0

[2] https://exoplayer.dev/

[3] https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.html

[4] https://github.com/google/ExoPlayer/tree/dev-v2/extensions/okhttp

반응형