Skip to content

MBL-2619: upgrade to media3 exoplayer #2389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Aug 7, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d664624
- From exoplayer 2 to Media3 exoplayer
Arkariang May 26, 2025
580cd79
Merge branch 'master' of github.com:kickstarter/android-oss into imar…
Arkariang May 28, 2025
7cc2c02
Merge branch 'master' of github.com:kickstarter/android-oss into imar…
Arkariang Jul 23, 2025
9201de4
no message
Arkariang Jul 23, 2025
14662f1
no message
Arkariang Jul 23, 2025
6e4abd5
no message
Arkariang Jul 23, 2025
ec5ce04
- Removed any unstable API related integrations
Arkariang Jul 23, 2025
c85ebc6
no message
Arkariang Jul 23, 2025
48102ff
no message
Arkariang Jul 23, 2025
51104f3
no message
Arkariang Jul 24, 2025
6949c3e
Merge branch 'master' into imartin/migrate-to-media3-exoplayer
Arkariang Jul 24, 2025
edc719c
Merge branch 'master' into imartin/migrate-to-media3-exoplayer
Arkariang Jul 24, 2025
b922bc7
no message
Arkariang Jul 24, 2025
857388f
Merge branch 'imartin/migrate-to-media3-exoplayer' of github.com:kick…
Arkariang Jul 24, 2025
526a56d
no message
Arkariang Jul 24, 2025
a186b2b
Merge branch 'master' into imartin/migrate-to-media3-exoplayer
Arkariang Jul 24, 2025
3985864
Merge branch 'master' into imartin/migrate-to-media3-exoplayer
Arkariang Jul 24, 2025
cd66130
Merge branch 'master' into imartin/migrate-to-media3-exoplayer
Arkariang Jul 29, 2025
0c03651
Merge branch 'master' into imartin/migrate-to-media3-exoplayer
Arkariang Aug 5, 2025
3cdda04
Merge branch 'master' of github.com:kickstarter/android-oss into imar…
Arkariang Aug 5, 2025
414d21f
Merge branch 'imartin/migrate-to-media3-exoplayer' of github.com:kick…
Arkariang Aug 5, 2025
14f7707
- Added user agent
Arkariang Aug 5, 2025
af63870
no message
Arkariang Aug 5, 2025
8f926d3
no message
Arkariang Aug 5, 2025
c16e520
Merge branch 'master' into imartin/migrate-to-media3-exoplayer
Arkariang Aug 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ dependencies {
implementation 'com.facebook.android:facebook-android-sdk:16.0.0'
implementation("com.google.android.play:review-ktx:2.0.1")
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'com.google.android.material:material:1.6.1'
final dagger_version = "2.46"
Expand Down Expand Up @@ -290,6 +289,11 @@ dependencies {
implementation "com.google.accompanist:accompanist-systemuicontroller:0.30.1"
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.2"

// Exoplayer
def exoplayer= '1.7.1'
implementation "androidx.media3:media3-exoplayer-hls:$exoplayer"
implementation "androidx.media3:media3-ui:$exoplayer"

// Coroutines
def coroutines = '1.7.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
Expand Down
187 changes: 84 additions & 103 deletions app/src/main/java/com/kickstarter/ui/activities/VideoActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,78 +2,69 @@ package com.kickstarter.ui.activities

import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build.VERSION
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.widget.ImageView
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.util.Util
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.kickstarter.R
import com.kickstarter.databinding.VideoPlayerLayoutBinding
import com.kickstarter.libs.Build
import com.kickstarter.libs.utils.WebUtils.userAgent
import com.kickstarter.libs.utils.extensions.addToDisposable
import com.kickstarter.libs.utils.extensions.getEnvironment
import com.kickstarter.ui.IntentKey
import com.kickstarter.ui.extensions.setUpConnectivityStatusCheck
import com.kickstarter.utils.WindowInsetsUtil
import com.kickstarter.viewmodels.VideoViewModel.Factory
import com.kickstarter.viewmodels.VideoViewModel.VideoViewModel
import com.kickstarter.viewmodels.VideoViewModel
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable

class VideoActivity : AppCompatActivity() {

private lateinit var build: Build
private var player: ExoPlayer? = null
private var playerPosition: Long? = null
private var trackSelector: DefaultTrackSelector? = null
private lateinit var binding: VideoPlayerLayoutBinding
private lateinit var viewModelFactory: VideoViewModel.Factory
private val viewModel: VideoViewModel.VideoViewModel by viewModels { viewModelFactory }

private lateinit var viewModelFactory: Factory
private val viewModel: VideoViewModel by viewModels { viewModelFactory }

private var disposables = CompositeDisposable()
private lateinit var player: ExoPlayer
private var playerPosition: Long? = null
private val disposables = CompositeDisposable()

private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
viewModel.inputs.resume()
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = VideoPlayerLayoutBinding.inflate(layoutInflater)
WindowInsetsUtil.manageEdgeToEdge(
window,
binding.root
)
WindowInsetsUtil.manageEdgeToEdge(window, binding.root)
setContentView(binding.root)

val environment = this.getEnvironment()?.let { env ->
viewModelFactory = Factory(env, intent = intent)
viewModelFactory = VideoViewModel.Factory(env, intent = intent)
env
}

build = requireNotNull(environment?.build())

val fullscreenButton: ImageView = binding.playerView.findViewById(R.id.exo_fullscreen_icon)
fullscreenButton.setImageResource(R.drawable.ic_fullscreen_close)
player = ExoPlayer.Builder(this)
.build()

fullscreenButton.setOnClickListener {
back()
binding.playerView.findViewById<ImageView>(R.id.exo_fullscreen_icon).apply {
setImageResource(R.drawable.ic_fullscreen_close)
setOnClickListener { back() }
}

viewModel.outputs.preparePlayerWithUrl()
Expand All @@ -91,37 +82,74 @@ class VideoActivity : AppCompatActivity() {

lifecycle.addObserver(lifecycleObserver)

this.onBackPressedDispatcher.addCallback {
back()
}
onBackPressedDispatcher.addCallback { back() }

setUpConnectivityStatusCheck(lifecycle)
}

public override fun onDestroy() {
super.onDestroy()
player = null
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
if (VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
enterImmersiveModeApi30()
} else {
systemUIFlags()
}
}
}

public override fun onPause() {
override fun onPause() {
super.onPause()
releasePlayer()
}

override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
binding.videoPlayerLayout.systemUiVisibility = systemUIFlags()
override fun onDestroy() {
releasePlayer()
disposables.clear()
super.onDestroy()
}

private fun preparePlayer(videoUrl: String) {
val mediaItem = MediaItem.Builder()
.setUri(videoUrl)
.build()

player.also {
binding.playerView.player = it
it.addListener(eventListener)
it.setMediaItem(mediaItem)
it.prepare()
playerPosition?.let(it::seekTo)
it.playWhenReady = true
}
}

private fun releasePlayer() {
player.also {
playerPosition = it.currentPosition
it.duration.takeIf { duration -> duration > 0 }?.let { duration ->
viewModel.inputs.onVideoCompleted(duration, playerPosition ?: 0L)
}
it.removeListener(eventListener)
it.release()
}
}

private fun back() {
val intent = Intent()
.putExtra(IntentKey.VIDEO_SEEK_POSITION, player?.currentPosition)
val intent = Intent().putExtra(IntentKey.VIDEO_SEEK_POSITION, player.currentPosition)
setResult(Activity.RESULT_OK, intent)
finish()
}

// For API 30+ (Android 11 and above)
@RequiresApi(android.os.Build.VERSION_CODES.R)
private fun enterImmersiveModeApi30() {
window.insetsController?.let { controller ->
controller.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}

private fun systemUIFlags(): Int {
return (
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
Expand All @@ -132,71 +160,24 @@ class VideoActivity : AppCompatActivity() {
}

private fun onStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
player?.duration?.let {
viewModel.inputs.onVideoStarted(it, playerPosition ?: 0L)
when (playbackState) {
Player.STATE_READY -> {
player.duration.let {
viewModel.inputs.onVideoStarted(it, playerPosition ?: 0L)
}
binding.loadingIndicator.visibility = View.GONE
}
}

if (playbackState == Player.STATE_ENDED) {
finish()
}

if (playbackState == Player.STATE_BUFFERING) {
binding.loadingIndicator.visibility = View.VISIBLE
} else {
binding.loadingIndicator.visibility = View.GONE
}
}
Player.STATE_ENDED -> finish()

private fun preparePlayer(videoUrl: String) {
val adaptiveTrackSelectionFactory: AdaptiveTrackSelection.Factory = AdaptiveTrackSelection.Factory()
trackSelector = DefaultTrackSelector(this, adaptiveTrackSelectionFactory)
trackSelector?.let {
ExoPlayer.Builder(this).setTrackSelector(it)
}
Player.STATE_BUFFERING -> binding.loadingIndicator.visibility = View.VISIBLE

val playerBuilder = ExoPlayer.Builder(this)
trackSelector?.let { playerBuilder.setTrackSelector(it) }
player = playerBuilder.build()

binding.playerView.player = player
player?.addListener(eventListener)

player?.setMediaSource(getMediaSource(videoUrl))
player?.prepare()
playerPosition?.let {
player?.seekTo(it)
}
player?.playWhenReady = true
}

private fun getMediaSource(videoUrl: String): MediaSource {
val dataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(userAgent(build))
val videoUri = Uri.parse(videoUrl)
val fileType = Util.inferContentType(videoUri)

return if (fileType == C.TYPE_HLS) {
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(videoUri))
Copy link
Contributor Author

@Arkariang Arkariang Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hence using media3-exoplayer-hls no need to make any special configurations. The DefaultMediaSourceFactory attempts to create the appropriate MediaSource based on the URI provided in the MediaItem (typically ending in .m3u8 for hls). It will try to instantiate HlsMediaSource.Factory internally

} else {
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(videoUri))
}
}

private fun releasePlayer() {
if (player != null) {
playerPosition = player?.currentPosition
player?.duration?.let {
viewModel.inputs.onVideoCompleted(it, playerPosition ?: 0L)
}
player?.removeListener(eventListener)
player?.release()
trackSelector = null
player = null
else -> binding.loadingIndicator.visibility = View.GONE
}
}

private val eventListener: Player.Listener = object : Player.Listener {
private val eventListener = @UnstableApi
object : Player.Listener {
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
onStateChanged(playbackState)
}
Expand Down
Loading