TAE

[Android/Kotlin] 숏츠 화면 만들기 (ExoPlayer) 본문

android

[Android/Kotlin] 숏츠 화면 만들기 (ExoPlayer)

tg-world 2023. 7. 12. 21:58
반응형

이번 포스팅에는 유튜브나 인스타 처럼 넘기는 영상 숏츠 화면을 만들어 보겠습니다.

위 처럼 짧은 영상을 넘기면서 볼 수 있는 숏츠 화면을 만들어 보겠습니다.

 

ViewPager2 + ExoPlayer 사용하여 만들어 보겠습니다.

 

Fragment에 ExoPlayer를 구성하여 만들었으며, Viewpager Adapger에 createFragment로 각 포지션마다 아이템을 넘겨 fragment를 구성하여 각 화면을 보여주게 됩니다.

그럼 소스 보며 하나씩 설명 드리겠습니다.

1. build.gradle (app)

    implementation "androidx.viewpager2:viewpager2:1.0.0"
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'com.google.android.exoplayer:exoplayer:2.15.1'

viewpager와 exoplayer를 build.gradle app단에 추가하여 줍니다.

 

2. activity_short.xml

<?xml version="1.0" encoding="utf-8"?>
<layout>

    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/shortsVP"
            android:saveEnabled="false"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical" />

    </LinearLayout>
</layout>

short를 만들 xml에 viewpager2를 추가하여 줍니다.

orientaiton을 vertical로 설정하게 되면 유튜브처럼 세로로 넘길수 있고 horizental로 설정하고 애니메이션을 주게 되면 인스타 스토리처럼 가로로 넘길수 있습니다.

 

3. ShortsActivity.kt

class ShortsActivity : AppCompatActivity() {
    private lateinit var binding: ActivityShortBinding
    private lateinit var videoAdapter: VideoPlayerAdapter
    private val mediaObjectList = ArrayList<ShortObject>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_short)


        val ShortObject1 = ShortObject()
        ShortObject1.setId(1)
        ShortObject1.setUserName("고양이 집사")
        ShortObject1.setContents("냥냥펀치 입니다~~")
        ShortObject1.setUrl("https://blog.kakaocdn.net/dn/cPjc1G/btslYxcRuz4/AqGJ0ifzNuvIY3ywsHMDv0/KakaoTalk_Video_2023-06-30-17-46-03.mp4?attach=1&knm=tfile.mp4")

        val ShortObject2 = ShortObject()
        ShortObject2.setId(2)
        ShortObject2.setUserName("하잉하잉")
        ShortObject2.setContents("고양이 영상입니다~ 집앞 고양이~")
        ShortObject2.setUrl("https://blog.kakaocdn.net/dn/bcVeEV/btslZPqcb1L/6d2xMDsAqOaqRK5eeoeQxK/KakaoTalk_Video_2023-06-30-17-47-22.mp4?attach=1&knm=tfile.mp4")

        val ShortObject3 = ShortObject()
        ShortObject3.setId(3)
        ShortObject3.setUserName("아기고양이")
        ShortObject3.setContents("아기 고양이 입니다!! 야옹")
        ShortObject3.setUrl("https://blog.kakaocdn.net/dn/riYGP/btslT0e0Zba/eIhERthBv1EmexAHCZF4PK/KakaoTalk_Video_2023-06-30-14-57-27.mp4?attach=1&knm=tfile.mp4")

        mediaObjectList.add(ShortObject1)
        mediaObjectList.add(ShortObject2)
        mediaObjectList.add(ShortObject3)


        videoAdapter = VideoPlayerAdapter(this, mediaObjectList)
        binding.shortsVP.adapter = videoAdapter
        binding.shortsVP.currentItem = 0
    }
}

VideoPlayerAdapter에 임시로 만든 ShortObject들을 넘겨줍니다.

ShortObject는 임시로 userName, Contents , mediaUrl을 담고있습니다.

 

4. ShortObject.kt

class ShortObject : Serializable {
    private var uId = 0
    private var contents: String? = null
    private var mediaUrl: String? = null
    private var userName: String? = null
    
    fun getId(): Int {
        return uId
    }

    fun setId(uId: Int) {
        this.uId = uId
    }

    fun getContents(): String? {
        return contents
    }

    fun setContents(mTitle: String?) {
        contents = mTitle
    }

    fun getUrl(): String? {
        return mediaUrl
    }

    fun setUrl(mUrl: String?) {
        mediaUrl = mUrl
    }

    fun getUserName(): String? {
        return userName
    }

    fun setUserName(mCoverUrl: String?) {
        userName = mCoverUrl
    }
}

숏츠 오브젝트의 정보를 담을 모델입니다

 

반응형

4. VideoPlayerAdapter

class VideoPlayerAdapter(fragmentActivity: FragmentActivity, arr: ArrayList<ShortObject>) :
    FragmentStateAdapter(fragmentActivity) {

    private var videoDetailList: ArrayList<ShortObject>? = arr

    override fun getItemCount(): Int {
        return videoDetailList!!.size
    }


    override fun getItemId(position: Int): Long {
        return super.getItemId(position)
    }

    override fun containsItem(itemId: Long): Boolean {
        return super.containsItem(itemId)
    }

    override fun createFragment(position: Int): Fragment {
        return VideoPlayerFragment.newInstance(
            videoDetailList?.get(position)
        )
    }
}

Activity에서 받은 ShortObject ArrayList를 받아 createFragment에서 newInstace로 각 포지션에 맞는 리스트 데이터들을 넘겨줍니다.

찾아보니 newInstance 대신 koin으로 사용하라는 이야기도 있었는데.. 우선 구현에 목적성을 두어 newInstance를 사용하겠습니다.

고도화 하시고 싶으시면 한번 찾아 보시는 걸 추천 드립니다.

 

5. fragment_video_player.xml

<?xml version="1.0" encoding="utf-8"?>
<layout>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/layout_root"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!--플레이어뷰-->
    <com.google.android.exoplayer2.ui.PlayerView
        android:visibility="visible"
        android:id="@+id/playerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/black"
        app:ad_marker_color="@color/black"
        app:buffered_color="@color/black"
        app:controller_layout_id="@layout/layout_video_controller"
        app:hide_on_touch="false"
        app:keep_content_on_player_reset="false"
        app:played_ad_marker_color="@color/black"
        app:played_color="@color/black"
        app:resize_mode="fit"
        app:scrubber_color="@color/black"
        app:show_timeout="0"
        app:shutter_background_color="@color/black"
        app:unplayed_color="@color/black" />

    <!--로딩 프로그래스바-->
    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:visibility="gone" />

</FrameLayout>
</layout>

 

playView 기본 속성으로는 아래를 참고하시면 됩니다.

  • bar_height- 타임 바의 높이 치수.
  • touch_target_height- 시간 표시줄과의 터치 상호 작용이 처리되는 영역의 높이에 대한 치수입니다. 높이를 지정하지 않으면 보기의 높이도 결정됩니다.
  • ad_marker_width- 표시줄에 표시되는 광고 마커의 너비 치수입니다. 광고 마커는 광고가 재생되는 시간을 표시하기 위해 시간 표시줄에 중첩됩니다.
  • scrubber_enabled_size- 스크러빙이 활성화되었지만 진행 중이 아닐 때 원형 스크러버 핸들의 직경 치수. 스크러버 핸들이 표시되지 않으면 0으로 설정하십시오.
  • scrubber_disabled_size- 스크러빙이 활성화되지 않은 경우 원형 스크러버 핸들의 직경 치수. 스크러버 핸들이 표시되지 않으면 0으로 설정하십시오.
  • scrubber_dragged_size- 스크러빙이 진행 중일 때 원형 스크러버 핸들의 직경에 대한 치수입니다. 스크러버 핸들이 표시되지 않으면 0으로 설정하십시오.
  • scrubber_drawable- 스크러버 핸들에 대해 그릴 드로어블에 대한 선택적 참조입니다. 설정하면 스크러버 핸들의 원을 그리는 기본 동작을 재정의합니다.
  • played_color- 현재 재생 위치 이전의 미디어를 나타내는 시간 표시줄 부분의 색상입니다.
  • scrubber_color- 스크러버 핸들의 색상.
  • buffered_color- 현재 재생 위치 이후부터 현재 버퍼링된 위치까지의 시간 표시줄 부분에 대한 색상입니다.
  • unplayed_color- 현재 버퍼링된 위치 이후의 시간 표시줄 부분에 대한 색상입니다.
  • ad_marker_color- 재생되지 않은 광고 마커의 색상.
  • played_ad_marker_color- 재생된 광고 마커의 색상.

6. VideoPlayerFramgent.kt

class VideoPlayerFragment : Fragment() {
    private lateinit var binding: FragmentVideoPlayerBinding
    private var player: SimpleExoPlayer? = null
    private var mIsVisibleToUser = false
    private var chkPlay: Boolean = true
    private lateinit var shortObject : ShortObject

    companion object {
        fun newInstance(mediaUrl: ShortObject?): VideoPlayerFragment {
            val args = Bundle().apply {
                putSerializable("mediaUrl", mediaUrl)
            }

            val fragment = VideoPlayerFragment()
            fragment.arguments = args
            return fragment
        }
    }

    override fun setMenuVisibility(menuVisible: Boolean) {
        super.setMenuVisibility(menuVisible)
        if (menuVisible) {
            mIsVisibleToUser = menuVisible
            playerControl(play = true, reset = false)
        } else {
            playerControl(play = false, reset = true)
        }

    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        try {
            shortObject = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                requireArguments().getSerializable("mediaUrl", ShortObject::class.java)!!
            } else {
                (requireArguments().getSerializable("mediaUrl") as? ShortObject)!!
            }
        } catch (e: java.lang.Exception) {

        }

    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding =
            DataBindingUtil.inflate(inflater, R.layout.fragment_video_player, container, false)
        initializePlayer()
        binding.playerView.setOnClickListener {
            playerControl(chkPlay, false)
        }
        setContents()
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        releasePlayer()
    }

    override fun onDestroy() {
        super.onDestroy()
        releasePlayer()
    }

    private fun releasePlayer() {
        if (player != null) {
            player!!.release()
            player = null
        }
    }

    private fun setContents(){
        binding.playerView.findViewById<TextView>(R.id.tv_nickname).text = shortObject.getUserName()
        binding.playerView.findViewById<TextView>(R.id.tv_text_short).text = shortObject.getContents()

    }
    private fun initializePlayer() {
        try {
            if (player == null) {
                val loadControl = DefaultLoadControl.Builder()
                    .setAllocator(DefaultAllocator(true, 16))
                    .setBufferDurationsMs(2000, 3000, 1500, 2000)
                    .setTargetBufferBytes(-1)
                    .setPrioritizeTimeOverSizeThresholds(true)
                    .createDefaultLoadControl()

                player =
                    SimpleExoPlayer.Builder(requireContext()) //   .setTrackSelector(trackSelector)
                        .setLoadControl(loadControl) //  .setBandwidthMeter(defaultBandwidthMeter)
                        .build()

                // 음소거 사용자 설정 유지
                binding.playerView.player = player
                binding.playerView.useController = true
                binding.playerView.showController()

                val mediaSource: MediaSource = buildMediaSource(Uri.parse(shortObject.getUrl()))!!
                if (player != null) {
                    player!!.prepare(mediaSource, true, false)
                    player!!.addListener(eventListener)
                }

            }
        } catch (e: Exception) {

        }
    }

    fun buildMediaSource(uri: Uri): MediaSource? {
        val userAgent = Util.getUserAgent(requireContext(), resources.getString(R.string.app_name))
        val defaultHttpDataSourceFactory = DefaultHttpDataSourceFactory(userAgent)
        val defaultDataSourceFactory: DataSource.Factory = DefaultDataSourceFactory(
            requireContext(), userAgent
        )
        val lastPathSegment = uri.lastPathSegment
        return if (lastPathSegment!!.contains("mp3") || lastPathSegment.contains("mp4")) {
            ProgressiveMediaSource.Factory(defaultHttpDataSourceFactory)
                .createMediaSource(uri)
        } else if (lastPathSegment.contains("m3u8")) {
            HlsMediaSource.Factory(defaultHttpDataSourceFactory)
                .setAllowChunklessPreparation(true)
                .createMediaSource(uri)
        } else if (lastPathSegment.contains("mpd")) {
            DashMediaSource.Factory(defaultDataSourceFactory)
                .createMediaSource(uri)
        } else {
            ProgressiveMediaSource.Factory(defaultDataSourceFactory)
                .createMediaSource(uri)
        }
    }

    private var eventListener: Player.EventListener = object : Player.EventListener {
        override fun onIsPlayingChanged(isPlaying: Boolean) {
            chkPlay = isPlaying
        }

        /**
         * @param playWhenReady - Whether playback will proceed when ready.
         * @param playbackState - One of the STATE constants.
         */
        override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
            when (playbackState) {
                SimpleExoPlayer.STATE_IDLE -> {
                }

                SimpleExoPlayer.STATE_BUFFERING -> {
                }

                SimpleExoPlayer.STATE_READY -> {
                }

                SimpleExoPlayer.STATE_ENDED -> {
                    playerControl(true, true)

                }

                else -> {
                }
            }
        }
    }

    fun playerControl(play: Boolean, reset: Boolean) {
        try {
            if (play) { // start
                if (mIsVisibleToUser) {
                    if (player != null && !player!!.isPlaying) {
                        if (reset) {
                            player!!.seekTo(0)
                        }
                        player!!.playWhenReady = true
                    }
                    if (binding.playerView != null) {
                        binding.playerView.onResume()
                    }

                }
            } else { // stop
                if (binding.playerView != null) {
                    binding.playerView.onPause()
                }
                if (player != null) {
                    player!!.playWhenReady = false
                    if (reset) {
                        player!!.seekTo(0)
                    }
                }
            }
        } catch (e: java.lang.Exception) {

        }
    }
}

코드 설명

 

bundle 로 ShortObject를 받아온 후 화면에 그려줍니다.

override fun setMenuVisibility(menuVisible: Boolean) {
    super.setMenuVisibility(menuVisible)
    if (menuVisible) {
        mIsVisibleToUser = menuVisible
        playerControl(play = true, reset = false)
    } else {
        playerControl(play = false, reset = true)
    }

}

setMenuVisibility는 현재 화면을 보고 있는지 여부를 넘겨줍니다.

menuVisible이 ture이면 playerControl 함수에서  영상 재생을 flase일 경우 영상 정지후 reset을 시켜줍니다.

 

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    binding =
        DataBindingUtil.inflate(inflater, R.layout.fragment_video_player, container, false)
    initializePlayer()
    binding.playerView.setOnClickListener {
        playerControl(chkPlay, false)
    }
    setContents()
    return binding.root
}

 

onCreateView에서 player를 init하는 함수를 실행하고 player클릭시 재생 멈춤 클릭 리스너를 등록하고, 해당 컨텐츠 내용을 init하여 줍니다.

 

참조

https://exoplayer.dev/doc/reference/constant-values.html

https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/ui/DefaultTimeBar.html

반응형
Comments