From cc5658f848a8e7f51894713f9268fb913bbf79a1 Mon Sep 17 00:00:00 2001 From: Michael Knoch Date: Mon, 23 Jun 2025 23:52:05 +0200 Subject: [PATCH 1/5] update to media3-exoplayer 1.7.1 --- build.gradle | 5 +- src/main/java/org/uikit/VideoJNI.kt | 221 ++++++++++++++-------------- 2 files changed, 111 insertions(+), 115 deletions(-) diff --git a/build.gradle b/build.gradle index dba86441..55c919bd 100644 --- a/build.gradle +++ b/build.gradle @@ -31,7 +31,10 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.google.android.exoplayer:exoplayer:2.13.3" + implementation "androidx.media3:media3-exoplayer:1.7.1" + implementation "androidx.media3:media3-ui:1.7.1" + implementation "androidx.media3:media3-datasource:1.7.1" + implementation "androidx.media3:media3-database:1.7.1" } repositories { diff --git a/src/main/java/org/uikit/VideoJNI.kt b/src/main/java/org/uikit/VideoJNI.kt index c4077869..0ef23a33 100644 --- a/src/main/java/org/uikit/VideoJNI.kt +++ b/src/main/java/org/uikit/VideoJNI.kt @@ -2,48 +2,59 @@ 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 androidx.media3.common.MediaItem +import androidx.media3.exoplayer.source.ProgressiveMediaSource import org.libsdl.app.SDLActivity +import androidx.media3.database.ExoDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +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 java.io.File +import android.widget.RelativeLayout +import android.util.Log +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.ui.PlayerView import kotlin.math.absoluteValue - @Suppress("unused") class AVURLAsset(parent: SDLActivity, url: String) { - internal val videoSource: ProgressiveMediaSource + internal val mediaSource: ProgressiveMediaSource private val context: Context = parent.context init { - val mediaItem = MediaItem.Builder().setUri(Uri.parse(url)).build() + val mediaItem = MediaItem.Builder() + .setUri(Uri.parse(url)) + .build() - // ExtractorMediaSource works for regular media files such as mp4, webm, mkv - val cacheDataSourceFactory = CacheDataSourceFactory( + // 512 MiB total cache, individual file parts ≤ 64 MiB + val cacheFactory = CacheDataSourceFactory( context, - 512 * 1024 * 1024, - 64 * 1024 * 1024 + maxCacheSize = 512L * 1024 * 1024, + maxFileSize = 64L * 1024 * 1024 ) - videoSource = ProgressiveMediaSource - .Factory(cacheDataSourceFactory) + mediaSource = ProgressiveMediaSource + .Factory(cacheFactory) .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,34 +65,39 @@ 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 != getCurrentTimeInMilliseconds()) { + 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) { // PlaybackException replaces ExoPlaybackException :contentReference[oaicite:1]{index=1} + Log.e("SDL", "PlaybackException: ${error.errorCodeName}") + nativeOnVideoError(error.errorCode, error.message ?: "N/A") } } @@ -89,12 +105,10 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { } fun play() { - // ExoPlayer API to play the video exoPlayer.playWhenReady = true } fun pause() { - // ExoPlayer API to pause the video exoPlayer.playWhenReady = false } @@ -102,42 +116,31 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { 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, /*pitch*/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 + if (isSeeking) return // already mid‑seek - // 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 } - - // 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) { + val seekParameters = if ((desiredSeekPosition - lastSeekedToTime).absoluteValue < 250) { 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 +151,55 @@ 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) } - - - -///// Caching data source -// Thank you https://stackoverflow.com/a/45488510/3086440 - -private class CacheDataSourceFactory(private val context: Context, private val maxCacheSize: Long, private val maxFileSize: Long) : DataSource.Factory { - - private val defaultDatasourceFactory: DefaultDataSourceFactory - - // The cache survives the application lifetime, otherwise the cache keys can get confused - companion object { - var simpleCache: SimpleCache? = null - } - - init { - val bandwidthMeter = DefaultBandwidthMeter.Builder(context).build() - defaultDatasourceFactory = DefaultDataSourceFactory(this.context, - bandwidthMeter, DefaultHttpDataSource.Factory()) +/** Data‑source factory that adds a read/write LRU cache around HTTP & file accesses. */ +internal class CacheDataSourceFactory( + private val context: Context, + private val maxCacheSize: Long, + private val maxFileSize: Long +) : DataSource.Factory { + + private val upstreamFactory = DefaultDataSource.Factory(context) + + private val simpleCache by lazy { + val cacheDir = File(context.cacheDir, "media") + SimpleCache( + cacheDir, + LeastRecentlyUsedCacheEvictor(maxCacheSize), + ExoDatabaseProvider(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) - } - - return CacheDataSource(simpleCache!!, defaultDatasourceFactory.createDataSource(), - FileDataSource(), CacheDataSink(simpleCache!!, maxFileSize), - CacheDataSource.FLAG_BLOCK_ON_CACHE or CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, null) - } + 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 + ) } \ No newline at end of file From 90d48af16edc21ce915923d95c069bf4e7319896 Mon Sep 17 00:00:00 2001 From: Michael Knoch Date: Tue, 24 Jun 2025 00:11:39 +0200 Subject: [PATCH 2/5] cleanup --- src/main/java/org/uikit/VideoJNI.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/uikit/VideoJNI.kt b/src/main/java/org/uikit/VideoJNI.kt index 0ef23a33..c71e8926 100644 --- a/src/main/java/org/uikit/VideoJNI.kt +++ b/src/main/java/org/uikit/VideoJNI.kt @@ -49,10 +49,8 @@ class AVURLAsset(parent: SDLActivity, url: String) { } } - @Suppress("unused") class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { - internal val exoPlayer: ExoPlayer private val listener: Player.Listener @@ -73,7 +71,7 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { prepare() } - listener = object : Player.Listener { + listener = object: Player.Listener { override fun onPlaybackStateChanged(state: Int) { when (state) { Player.STATE_READY -> nativeOnVideoReady() @@ -129,8 +127,13 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { private fun seekToTimeInMilliseconds(timeMs: Long) { desiredSeekPosition = timeMs - if (isSeeking) return // already mid‑seek + // 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 + + // 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 seekParameters = if ((desiredSeekPosition - lastSeekedToTime).absoluteValue < 250) { SeekParameters.EXACT } else { @@ -174,13 +177,12 @@ class AVPlayerLayer(private val parent: SDLActivity, player: AVPlayer) { fun removeFromParent() = parent.removeViewInLayout(exoPlayerView) } -/** Data‑source factory that adds a read/write LRU cache around HTTP & file accesses. */ +// Data‑source factory that adds a read/write LRU cache around HTTP & file accesses internal class CacheDataSourceFactory( private val context: Context, private val maxCacheSize: Long, private val maxFileSize: Long ) : DataSource.Factory { - private val upstreamFactory = DefaultDataSource.Factory(context) private val simpleCache by lazy { @@ -202,4 +204,4 @@ internal class CacheDataSourceFactory( CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, null ) -} \ No newline at end of file +} From 36ec9a23224fdb1bdfcd3acef35695297c53729c Mon Sep 17 00:00:00 2001 From: Michael Knoch Date: Tue, 24 Jun 2025 10:30:52 +0200 Subject: [PATCH 3/5] improve caching --- build.gradle | 3 +- src/main/java/org/uikit/VideoJNI.kt | 157 +++++++++++++++++----------- 2 files changed, 97 insertions(+), 63 deletions(-) diff --git a/build.gradle b/build.gradle index 55c919bd..74bb6041 100644 --- a/build.gradle +++ b/build.gradle @@ -33,8 +33,9 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.media3:media3-exoplayer:1.7.1" implementation "androidx.media3:media3-ui:1.7.1" - implementation "androidx.media3:media3-datasource:1.7.1" implementation "androidx.media3:media3-database:1.7.1" + implementation "androidx.media3:media3-datasource-okhttp:1.7.1" + implementation "com.squareup.okhttp3:okhttp:4.12.0" } repositories { diff --git a/src/main/java/org/uikit/VideoJNI.kt b/src/main/java/org/uikit/VideoJNI.kt index c71e8926..38566ae5 100644 --- a/src/main/java/org/uikit/VideoJNI.kt +++ b/src/main/java/org/uikit/VideoJNI.kt @@ -2,53 +2,34 @@ package org.uikit import android.content.Context import android.net.Uri +import android.widget.RelativeLayout +import android.util.Log import androidx.media3.common.MediaItem -import androidx.media3.exoplayer.source.ProgressiveMediaSource -import org.libsdl.app.SDLActivity +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.DefaultDataSource 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 java.io.File -import android.widget.RelativeLayout -import android.util.Log -import androidx.media3.common.PlaybackException -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player +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 mediaSource: ProgressiveMediaSource - private val context: Context = parent.context - - init { - val mediaItem = MediaItem.Builder() - .setUri(Uri.parse(url)) - .build() - - // 512 MiB total cache, individual file parts ≤ 64 MiB - val cacheFactory = CacheDataSourceFactory( - context, - maxCacheSize = 512L * 1024 * 1024, - maxFileSize = 64L * 1024 * 1024 - ) - - mediaSource = ProgressiveMediaSource - .Factory(cacheFactory) - .createMediaSource(mediaItem) - } -} - @Suppress("unused") class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { internal val exoPlayer: ExoPlayer @@ -71,7 +52,7 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { prepare() } - listener = object: Player.Listener { + listener = object : Player.Listener { override fun onPlaybackStateChanged(state: Int) { when (state) { Player.STATE_READY -> nativeOnVideoReady() @@ -87,14 +68,14 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { ) { if (reason == Player.DISCONTINUITY_REASON_SEEK) { isSeeking = false - if (desiredSeekPosition != getCurrentTimeInMilliseconds()) { + if (desiredSeekPosition != exoPlayer.currentPosition) { seekToTimeInMilliseconds(desiredSeekPosition) } } } - override fun onPlayerError(error: PlaybackException) { // PlaybackException replaces ExoPlaybackException :contentReference[oaicite:1]{index=1} - Log.e("SDL", "PlaybackException: ${error.errorCodeName}") + override fun onPlayerError(error: PlaybackException) { + Log.e("SDL", "PlaybackException: ${'$'}{error.errorCodeName}") nativeOnVideoError(error.errorCode, error.message ?: "N/A") } } @@ -115,10 +96,9 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { } fun getCurrentTimeInMilliseconds(): Long = exoPlayer.currentPosition - fun getPlaybackRate(): Float = exoPlayer.playbackParameters.speed fun setPlaybackRate(rate: Float) { - exoPlayer.setPlaybackParameters(PlaybackParameters(rate, /*pitch*/1.0f)) + exoPlayer.setPlaybackParameters(PlaybackParameters(rate, 1.0f)) } private var isSeeking = false @@ -127,21 +107,11 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { 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 - - // 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 seekParameters = if ((desiredSeekPosition - lastSeekedToTime).absoluteValue < 250) { - SeekParameters.EXACT - } else { - SeekParameters.CLOSEST_SYNC - } - + val delta = (desiredSeekPosition - lastSeekedToTime).absoluteValue + val mode = if (delta < 250) SeekParameters.EXACT else SeekParameters.CLOSEST_SYNC isSeeking = true - exoPlayer.setSeekParameters(seekParameters) + exoPlayer.setSeekParameters(mode) exoPlayer.seekTo(timeMs) lastSeekedToTime = timeMs } @@ -177,31 +147,94 @@ class AVPlayerLayer(private val parent: SDLActivity, player: AVPlayer) { fun removeFromParent() = parent.removeViewInLayout(exoPlayerView) } -// Data‑source factory that adds a read/write LRU cache around HTTP & file accesses +/** + * Data-source factory that wraps a single shared OkHttpClient + SimpleCache. + */ internal class CacheDataSourceFactory( private val context: Context, private val maxCacheSize: Long, private val maxFileSize: Long ) : DataSource.Factory { - private val upstreamFactory = DefaultDataSource.Factory(context) - - private val simpleCache by lazy { - val cacheDir = File(context.cacheDir, "media") - SimpleCache( - cacheDir, - LeastRecentlyUsedCacheEvictor(maxCacheSize), - ExoDatabaseProvider(context) + + init { + // Initialize the singleton with context and cache sizes + Media3Singleton.init( + context = context, + httpCacheSize = 20L * 1024 * 1024, + mediaCacheSize = maxCacheSize ) } + 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, + 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 + + lateinit var simpleCache: SimpleCache + private set + + /** + * Initialize once with application context and cache sizes. + */ + fun init(context: Context, httpCacheSize: Long, mediaCacheSize: Long) { + if (initialized) return + initialized = true + + // ① 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)) + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + + // ② 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 { + // Ensure our singletons are initialized (512 MiB cache, 20 MiB HTTP cache) + Media3Singleton.init( + context = context, + httpCacheSize = 20L * 1024 * 1024, + mediaCacheSize = 512L * 1024 * 1024 + ) + + val mediaItem = MediaItem.fromUri(Uri.parse(url)) + val cacheFactory = CacheDataSourceFactory( + context = context, + maxCacheSize = 512L * 1024 * 1024, + maxFileSize = 64L * 1024 * 1024 + ) + mediaSource = ProgressiveMediaSource.Factory(cacheFactory) + .createMediaSource(mediaItem) + } +} \ No newline at end of file From 98490c5a4c08090d15cc36f857c0ab371bf36353 Mon Sep 17 00:00:00 2001 From: Michael Knoch Date: Tue, 24 Jun 2025 10:38:19 +0200 Subject: [PATCH 4/5] cleanup --- src/main/java/org/uikit/VideoJNI.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/uikit/VideoJNI.kt b/src/main/java/org/uikit/VideoJNI.kt index 38566ae5..a207974e 100644 --- a/src/main/java/org/uikit/VideoJNI.kt +++ b/src/main/java/org/uikit/VideoJNI.kt @@ -107,11 +107,18 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { 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 + val delta = (desiredSeekPosition - lastSeekedToTime).absoluteValue - val mode = if (delta < 250) SeekParameters.EXACT else SeekParameters.CLOSEST_SYNC + + // 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 seekParameters = if (delta < 250) SeekParameters.EXACT else SeekParameters.CLOSEST_SYNC isSeeking = true - exoPlayer.setSeekParameters(mode) + exoPlayer.setSeekParameters(seekParameters) exoPlayer.seekTo(timeMs) lastSeekedToTime = timeMs } @@ -221,7 +228,6 @@ class AVURLAsset(parent: SDLActivity, url: String) { private val context: Context = parent.context init { - // Ensure our singletons are initialized (512 MiB cache, 20 MiB HTTP cache) Media3Singleton.init( context = context, httpCacheSize = 20L * 1024 * 1024, @@ -237,4 +243,4 @@ class AVURLAsset(parent: SDLActivity, url: String) { mediaSource = ProgressiveMediaSource.Factory(cacheFactory) .createMediaSource(mediaItem) } -} \ No newline at end of file +} From bdef0461941acbdf6857b66ffcf286d869703afc Mon Sep 17 00:00:00 2001 From: Michael Knoch Date: Tue, 24 Jun 2025 11:30:38 +0200 Subject: [PATCH 5/5] cleanup --- src/main/java/org/uikit/VideoJNI.kt | 36 ++++++++++------------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/uikit/VideoJNI.kt b/src/main/java/org/uikit/VideoJNI.kt index a207974e..a078903e 100644 --- a/src/main/java/org/uikit/VideoJNI.kt +++ b/src/main/java/org/uikit/VideoJNI.kt @@ -116,7 +116,7 @@ class AVPlayer(parent: SDLActivity, asset: AVURLAsset) { // 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 seekParameters = if (delta < 250) SeekParameters.EXACT else SeekParameters.CLOSEST_SYNC + val seekParameters = if (delta < 150) SeekParameters.EXACT else SeekParameters.CLOSEST_SYNC isSeeking = true exoPlayer.setSeekParameters(seekParameters) exoPlayer.seekTo(timeMs) @@ -155,23 +155,9 @@ class AVPlayerLayer(private val parent: SDLActivity, player: AVPlayer) { } /** - * Data-source factory that wraps a single shared OkHttpClient + SimpleCache. + * Data-source factory that reuses the singleton cache and HTTP client. */ -internal class CacheDataSourceFactory( - private val context: Context, - private val maxCacheSize: Long, - private val maxFileSize: Long -) : DataSource.Factory { - - init { - // Initialize the singleton with context and cache sizes - Media3Singleton.init( - context = context, - httpCacheSize = 20L * 1024 * 1024, - mediaCacheSize = maxCacheSize - ) - } - +internal class CacheDataSourceFactory(private val maxFileSize: Long): DataSource.Factory { private val upstreamFactory = OkHttpDataSource.Factory(Media3Singleton.okHttpClient) private val simpleCache = Media3Singleton.simpleCache @@ -186,6 +172,7 @@ internal class CacheDataSourceFactory( ) } + /** * Singleton holder for shared OkHttpClient (HTTP/2) and ExoPlayer disk cache. */ @@ -205,17 +192,15 @@ object Media3Singleton { if (initialized) return initialized = true - // ① Shared OkHttpClient with HTTP/2 support and disk cache + // 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)) - .connectTimeout(8, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) .build() - // ② Shared ExoPlayer disk cache (LRU evictor) + // 2. Shared ExoPlayer disk cache (LRU evictor) val mediaCacheDir = File(context.cacheDir, "media") val evictor = LeastRecentlyUsedCacheEvictor(mediaCacheSize) simpleCache = SimpleCache(mediaCacheDir, evictor, ExoDatabaseProvider(context)) @@ -230,15 +215,18 @@ class AVURLAsset(parent: SDLActivity, url: String) { init { Media3Singleton.init( context = context, + + // the http cache holds HTTP responses/validators (ETags, headers, small bodies), + // so we get fast 304s and header compression. httpCacheSize = 20L * 1024 * 1024, + + // this cache actually holds the raw MP4 bytes mediaCacheSize = 512L * 1024 * 1024 ) val mediaItem = MediaItem.fromUri(Uri.parse(url)) val cacheFactory = CacheDataSourceFactory( - context = context, - maxCacheSize = 512L * 1024 * 1024, - maxFileSize = 64L * 1024 * 1024 + maxFileSize = 256L * 1024 * 1024 ) mediaSource = ProgressiveMediaSource.Factory(cacheFactory) .createMediaSource(mediaItem)