-
Notifications
You must be signed in to change notification settings - Fork 39
update video player to media3-exoplayer and improve caching #403
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,48 +2,38 @@ package org.uikit | |
|
||
import android.content.Context | ||
import android.net.Uri | ||
import android.util.Log | ||
import android.widget.RelativeLayout | ||
import com.google.android.exoplayer2.* | ||
import com.google.android.exoplayer2.database.ExoDatabaseProvider | ||
import com.google.android.exoplayer2.source.ProgressiveMediaSource | ||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector | ||
import com.google.android.exoplayer2.ui.PlayerView | ||
import com.google.android.exoplayer2.upstream.* | ||
import com.google.android.exoplayer2.upstream.cache.CacheDataSink | ||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource | ||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor | ||
import com.google.android.exoplayer2.upstream.cache.SimpleCache | ||
import android.util.Log | ||
import androidx.media3.common.MediaItem | ||
import androidx.media3.common.PlaybackException | ||
import androidx.media3.common.PlaybackParameters | ||
import androidx.media3.common.Player | ||
import androidx.media3.database.ExoDatabaseProvider | ||
import androidx.media3.datasource.DataSource | ||
import androidx.media3.datasource.FileDataSource | ||
import androidx.media3.datasource.cache.CacheDataSink | ||
import androidx.media3.datasource.cache.CacheDataSource | ||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor | ||
import androidx.media3.datasource.cache.SimpleCache | ||
import androidx.media3.datasource.okhttp.OkHttpDataSource | ||
import androidx.media3.exoplayer.ExoPlayer | ||
import androidx.media3.exoplayer.SeekParameters | ||
import androidx.media3.exoplayer.source.ProgressiveMediaSource | ||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector | ||
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter | ||
import androidx.media3.ui.PlayerView | ||
import org.libsdl.app.SDLActivity | ||
import okhttp3.Cache as OkHttpCache | ||
import okhttp3.OkHttpClient | ||
import okhttp3.Protocol | ||
import java.io.File | ||
import java.util.concurrent.TimeUnit | ||
import kotlin.math.absoluteValue | ||
|
||
|
||
@Suppress("unused") | ||
class AVURLAsset(parent: SDLActivity, url: String) { | ||
internal val videoSource: ProgressiveMediaSource | ||
private val context: Context = parent.context | ||
|
||
init { | ||
val mediaItem = MediaItem.Builder().setUri(Uri.parse(url)).build() | ||
|
||
// ExtractorMediaSource works for regular media files such as mp4, webm, mkv | ||
val cacheDataSourceFactory = CacheDataSourceFactory( | ||
context, | ||
512 * 1024 * 1024, | ||
64 * 1024 * 1024 | ||
) | ||
|
||
videoSource = ProgressiveMediaSource | ||
.Factory(cacheDataSourceFactory) | ||
.createMediaSource(mediaItem) | ||
} | ||
} | ||
|
||
@Suppress("unused") | ||
class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { | ||
internal val exoPlayer: SimpleExoPlayer | ||
private var listener: Player.EventListener | ||
internal val exoPlayer: ExoPlayer | ||
private val listener: Player.Listener | ||
|
||
external fun nativeOnVideoReady() | ||
external fun nativeOnVideoEnded() | ||
|
@@ -54,90 +44,83 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { | |
val bandwidthMeter = DefaultBandwidthMeter.Builder(parent.context).build() | ||
val trackSelector = DefaultTrackSelector(parent.context) | ||
|
||
exoPlayer = SimpleExoPlayer.Builder(parent.context) | ||
.setBandwidthMeter(bandwidthMeter) | ||
.setTrackSelector(trackSelector) | ||
.build() | ||
exoPlayer.prepare() | ||
exoPlayer.setMediaSource(asset.videoSource) | ||
exoPlayer = ExoPlayer.Builder(parent.context) | ||
.setBandwidthMeter(bandwidthMeter) | ||
.setTrackSelector(trackSelector) | ||
.build().apply { | ||
setMediaSource(asset.mediaSource) | ||
prepare() | ||
} | ||
|
||
listener = object: Player.EventListener { | ||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { | ||
when (playbackState) { | ||
listener = object : Player.Listener { | ||
override fun onPlaybackStateChanged(state: Int) { | ||
when (state) { | ||
Player.STATE_READY -> nativeOnVideoReady() | ||
Player.STATE_ENDED -> nativeOnVideoEnded() | ||
Player.STATE_BUFFERING -> nativeOnVideoBuffering() | ||
else -> {} | ||
Player.STATE_ENDED -> nativeOnVideoEnded() | ||
} | ||
} | ||
|
||
override fun onSeekProcessed() { | ||
isSeeking = false | ||
if (desiredSeekPosition != getCurrentTimeInMilliseconds()) { | ||
seekToTimeInMilliseconds(desiredSeekPosition) | ||
override fun onPositionDiscontinuity( | ||
oldPosition: Player.PositionInfo, | ||
newPosition: Player.PositionInfo, | ||
reason: Int | ||
) { | ||
if (reason == Player.DISCONTINUITY_REASON_SEEK) { | ||
isSeeking = false | ||
if (desiredSeekPosition != exoPlayer.currentPosition) { | ||
seekToTimeInMilliseconds(desiredSeekPosition) | ||
} | ||
} | ||
} | ||
|
||
override fun onPlayerError(error: ExoPlaybackException) { | ||
Log.e("SDL", "ExoPlaybackException occurred") | ||
val message = error.message ?: "N/A" | ||
nativeOnVideoError(error.type, message) | ||
override fun onPlayerError(error: PlaybackException) { | ||
Log.e("SDL", "PlaybackException: ${'$'}{error.errorCodeName}") | ||
nativeOnVideoError(error.errorCode, error.message ?: "N/A") | ||
} | ||
} | ||
|
||
exoPlayer.addListener(listener) | ||
} | ||
|
||
fun play() { | ||
// ExoPlayer API to play the video | ||
exoPlayer.playWhenReady = true | ||
} | ||
|
||
fun pause() { | ||
// ExoPlayer API to pause the video | ||
exoPlayer.playWhenReady = false | ||
ephemer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
fun setVolume(newVolume: Double) { | ||
exoPlayer.volume = newVolume.toFloat() | ||
} | ||
|
||
fun getCurrentTimeInMilliseconds(): Long { | ||
return exoPlayer.currentPosition | ||
fun getCurrentTimeInMilliseconds(): Long = exoPlayer.currentPosition | ||
fun getPlaybackRate(): Float = exoPlayer.playbackParameters.speed | ||
fun setPlaybackRate(rate: Float) { | ||
exoPlayer.setPlaybackParameters(PlaybackParameters(rate, 1.0f)) | ||
} | ||
|
||
|
||
private var isSeeking = false | ||
private var desiredSeekPosition: Long = 0 | ||
private var lastSeekedToTime: Long = 0 | ||
|
||
private fun seekToTimeInMilliseconds(timeInMilliseconds: Long) { | ||
desiredSeekPosition = timeInMilliseconds | ||
private fun seekToTimeInMilliseconds(timeMs: Long) { | ||
desiredSeekPosition = timeMs | ||
|
||
// This *should* mean we don't always scroll to the last position provided. | ||
// In practice we always seem to be at the position we want anyway: | ||
if (isSeeking) { return } | ||
|
||
if (isSeeking) return | ||
|
||
val delta = (desiredSeekPosition - lastSeekedToTime).absoluteValue | ||
|
||
// Seeking to the exact millisecond is very processor intensive (and SLOW!) | ||
// Only do put that effort in if we're scrubbing very slowly over a short time period: | ||
val syncParameters = if ((desiredSeekPosition - lastSeekedToTime).absoluteValue < 250) { | ||
SeekParameters.EXACT | ||
} else { | ||
SeekParameters.CLOSEST_SYNC | ||
} | ||
|
||
val seekParameters = if (delta < 150) SeekParameters.EXACT else SeekParameters.CLOSEST_SYNC | ||
isSeeking = true | ||
exoPlayer.setSeekParameters(syncParameters) | ||
exoPlayer.seekTo(timeInMilliseconds) | ||
lastSeekedToTime = timeInMilliseconds | ||
} | ||
|
||
fun getPlaybackRate(): Float { | ||
return exoPlayer.playbackParameters.speed | ||
} | ||
|
||
fun setPlaybackRate(rate: Float) { | ||
exoPlayer.setPlaybackParameters(PlaybackParameters(rate, 1.0F)) | ||
exoPlayer.setSeekParameters(seekParameters) | ||
exoPlayer.seekTo(timeMs) | ||
lastSeekedToTime = timeMs | ||
} | ||
|
||
fun cleanup() { | ||
|
@@ -148,65 +131,104 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { | |
|
||
@Suppress("unused") | ||
class AVPlayerLayer(private val parent: SDLActivity, player: AVPlayer) { | ||
private var exoPlayerLayout: PlayerView | ||
private val exoPlayerView: PlayerView = PlayerView(parent.context).apply { | ||
useController = false | ||
tag = "ExoPlayer" | ||
this.player = player.exoPlayer | ||
} | ||
|
||
init { | ||
val context = parent.context | ||
|
||
exoPlayerLayout = PlayerView(context) | ||
exoPlayerLayout.player = player.exoPlayer | ||
exoPlayerLayout.useController = false | ||
exoPlayerLayout.tag = "ExoPlayer" | ||
parent.addView(exoPlayerLayout, 0) | ||
parent.addView(exoPlayerView, 0) | ||
} | ||
|
||
fun setFrame(x: Int, y: Int, width: Int, height: Int) { | ||
val layoutParams = RelativeLayout.LayoutParams(width, height) | ||
layoutParams.setMargins(x, y, 0, 0) | ||
exoPlayerLayout.layoutParams = layoutParams | ||
exoPlayerView.layoutParams = RelativeLayout.LayoutParams(width, height).also { | ||
it.setMargins(x, y, 0, 0) | ||
} | ||
} | ||
|
||
fun setResizeMode(resizeMode: Int) { | ||
exoPlayerLayout.resizeMode = resizeMode | ||
exoPlayerView.resizeMode = resizeMode | ||
} | ||
|
||
fun removeFromParent() { | ||
Log.v("SDL", "Removing video from parent layout") | ||
parent.removeViewInLayout(exoPlayerLayout) | ||
} | ||
fun removeFromParent() = parent.removeViewInLayout(exoPlayerView) | ||
} | ||
|
||
/** | ||
* Data-source factory that reuses the singleton cache and HTTP client. | ||
*/ | ||
internal class CacheDataSourceFactory(private val maxFileSize: Long): DataSource.Factory { | ||
private val upstreamFactory = OkHttpDataSource.Factory(Media3Singleton.okHttpClient) | ||
private val simpleCache = Media3Singleton.simpleCache | ||
|
||
override fun createDataSource(): DataSource = | ||
CacheDataSource( | ||
simpleCache, | ||
upstreamFactory.createDataSource(), | ||
FileDataSource(), | ||
CacheDataSink(simpleCache, maxFileSize), | ||
CacheDataSource.FLAG_BLOCK_ON_CACHE or CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, | ||
null | ||
) | ||
} | ||
|
||
|
||
/** | ||
* Singleton holder for shared OkHttpClient (HTTP/2) and ExoPlayer disk cache. | ||
*/ | ||
object Media3Singleton { | ||
private var initialized = false | ||
|
||
lateinit var okHttpClient: OkHttpClient | ||
private set | ||
|
||
///// Caching data source | ||
// Thank you https://stackoverflow.com/a/45488510/3086440 | ||
lateinit var simpleCache: SimpleCache | ||
private set | ||
|
||
private class CacheDataSourceFactory(private val context: Context, private val maxCacheSize: Long, private val maxFileSize: Long) : DataSource.Factory { | ||
/** | ||
* Initialize once with application context and cache sizes. | ||
*/ | ||
fun init(context: Context, httpCacheSize: Long, mediaCacheSize: Long) { | ||
if (initialized) return | ||
initialized = true | ||
|
||
private val defaultDatasourceFactory: DefaultDataSourceFactory | ||
// 1. Shared OkHttpClient with HTTP/2 support and disk cache | ||
val httpCacheDir = File(context.cacheDir, "http_http2_cache") | ||
val okCache = OkHttpCache(httpCacheDir, httpCacheSize) | ||
okHttpClient = OkHttpClient.Builder() | ||
.cache(okCache) | ||
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1)) | ||
.build() | ||
|
||
// The cache survives the application lifetime, otherwise the cache keys can get confused | ||
companion object { | ||
var simpleCache: SimpleCache? = null | ||
// 2. Shared ExoPlayer disk cache (LRU evictor) | ||
val mediaCacheDir = File(context.cacheDir, "media") | ||
val evictor = LeastRecentlyUsedCacheEvictor(mediaCacheSize) | ||
simpleCache = SimpleCache(mediaCacheDir, evictor, ExoDatabaseProvider(context)) | ||
} | ||
} | ||
|
||
@Suppress("unused") | ||
class AVURLAsset(parent: SDLActivity, url: String) { | ||
internal val mediaSource: ProgressiveMediaSource | ||
private val context: Context = parent.context | ||
|
||
init { | ||
val bandwidthMeter = DefaultBandwidthMeter.Builder(context).build() | ||
defaultDatasourceFactory = DefaultDataSourceFactory(this.context, | ||
bandwidthMeter, DefaultHttpDataSource.Factory()) | ||
} | ||
Media3Singleton.init( | ||
context = context, | ||
|
||
override fun createDataSource(): DataSource { | ||
if (simpleCache == null) { | ||
val cacheEvictor = LeastRecentlyUsedCacheEvictor(maxCacheSize) | ||
val dataBaseProvider = ExoDatabaseProvider(context) | ||
val file = File(context.cacheDir, "media") | ||
simpleCache = SimpleCache(file, cacheEvictor, dataBaseProvider) | ||
} | ||
// the http cache holds HTTP responses/validators (ETags, headers, small bodies), | ||
// so we get fast 304s and header compression. | ||
httpCacheSize = 20L * 1024 * 1024, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do you know what the difference is here? why are they different sizes? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I just restructured the initialization and size definitions a bit to make it easier to follow There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok sounds good to me! thanks. might be worth leaving a code comment with some of that info |
||
|
||
return CacheDataSource(simpleCache!!, defaultDatasourceFactory.createDataSource(), | ||
FileDataSource(), CacheDataSink(simpleCache!!, maxFileSize), | ||
CacheDataSource.FLAG_BLOCK_ON_CACHE or CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, null) | ||
// this cache actually holds the raw MP4 bytes | ||
mediaCacheSize = 512L * 1024 * 1024 | ||
) | ||
|
||
val mediaItem = MediaItem.fromUri(Uri.parse(url)) | ||
val cacheFactory = CacheDataSourceFactory( | ||
maxFileSize = 256L * 1024 * 1024 | ||
) | ||
mediaSource = ProgressiveMediaSource.Factory(cacheFactory) | ||
.createMediaSource(mediaItem) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok I'm confused. there's media3 which contains ExoPlayer now? I thought they just renamed it 😵
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Jetpack Media3’s ExoPlayer module is still ExoPlayer, it's just repackaged under the AndroidX Media3 umbrella (whatever androidx is, lol)