perf(video): warm up ExoPlayer pool, tune LoadControl, fix notification fall-through
Three small infrastructure fixes that support the eager-prepare model (every visible feed video calls setMediaItem + prepare immediately so it's ready when the user scrolls to it). - ExoPlayerPool.create(): the warmup that builds poolStartingSize players up front was already written but never invoked. Wire it from PlaybackService.lazyPool() the first time a pool is requested. The builds now run on the pool's main-looper scope with a yield() between each so they're spread across frames instead of stalling the UI in one ~150–600 ms burst. Idempotent via an AtomicBoolean. - ExoPlayerBuilder: install a feed-tuned DefaultLoadControl (10s/15s/750ms/2000ms) instead of the 50s/50s/2.5s/5s defaults. Every visible video preloads, so 5 simultaneous players were each trying to buffer 50s ahead — fighting for network and burning ~30 MB of buffer per HD player. Capping at 15s keeps the active video smooth, lets it start playing as soon as ~750 ms is buffered, and slashes peak memory on feeds with several preloads. - PlaybackService.onUpdateNotification: the third forEachIndexed loop was missing its return, so on the muted-but-playing fallback path super.onUpdateNotification was called once per playing session instead of once total. With multiple feed videos preloading simultaneously this was hammering the notification system every time a player changed state. Match the first two loops by returning after the first match. Also drop the unused `idx` from forEachIndexed.
This commit is contained in:
+26
@@ -24,6 +24,7 @@ import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
@@ -42,10 +43,35 @@ class ExoPlayerBuilder(
|
||||
.Builder(context)
|
||||
.apply {
|
||||
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
|
||||
setLoadControl(feedTunedLoadControl())
|
||||
}.build()
|
||||
.apply {
|
||||
addListener(AspectRatioCacher(MediaAspectRatioCache))
|
||||
addListener(KeepVideosPlaying(this))
|
||||
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video
|
||||
// in the feed is prepared eagerly so it's ready when the user scrolls to it; with the
|
||||
// default settings 5 simultaneous preloads would fight for ~250s of buffer between them
|
||||
// and chew through ~30+ MB per HD player. Feed playback is optimized for "the active
|
||||
// video plays smoothly while a few neighbours stay warm," so we cap the buffer at ~15s
|
||||
// and let playback kick in as soon as ~750 ms is buffered. Fullscreen still gets a
|
||||
// healthy buffer because seeks-within-15s are virtually instant from disk cache.
|
||||
private fun feedTunedLoadControl() =
|
||||
DefaultLoadControl
|
||||
.Builder()
|
||||
.setBufferDurationsMs(
|
||||
// minBufferMs =
|
||||
10_000,
|
||||
// maxBufferMs =
|
||||
15_000,
|
||||
// bufferForPlaybackMs =
|
||||
750,
|
||||
// bufferForPlaybackAfterRebufferMs =
|
||||
2_000,
|
||||
).setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -34,7 +34,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.yield
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class ExoPlayerPool(
|
||||
@@ -54,9 +56,26 @@ class ExoPlayerPool(
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
// Guards against firing the warmup more than once if create() is called repeatedly
|
||||
// (e.g. on reconfiguration or when both pools share a startup hook).
|
||||
private val warmupStarted = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Pre-warms the pool with [poolStartingSize] ExoPlayer instances on the main looper, yielding
|
||||
* between each build so the warmup is spread across frames instead of stalling the UI in one
|
||||
* burst. ExoPlayer must be constructed on the same thread that will operate it (the main
|
||||
* thread for this pool), so we cannot fan out across IO threads here. Idempotent — additional
|
||||
* calls are no-ops.
|
||||
*/
|
||||
fun create(context: Context) {
|
||||
while (playerPool.size < poolStartingSize) {
|
||||
playerPool.offer(builder.build(context))
|
||||
if (!warmupStarted.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
while (playerPool.size < poolStartingSize) {
|
||||
playerPool.offer(builder.build(context))
|
||||
// Hand the frame back so an in-flight onGetSession / acquirePlayer / layout
|
||||
// pass isn't blocked behind the next build.
|
||||
yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-5
@@ -105,7 +105,15 @@ class PlaybackService : MediaSessionService() {
|
||||
val blossomServerResolver = Amethyst.instance.blossomResolver
|
||||
|
||||
// creates new
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it }
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver)
|
||||
.also {
|
||||
poolNoProxy = it
|
||||
// Kick off the player pool warmup as soon as we know this pool is being used.
|
||||
// It runs async on the main looper, yielding between builds, so the very first
|
||||
// session still acquires synchronously while subsequent ones can grab a warm
|
||||
// ExoPlayer instead of paying the build cost on the main thread.
|
||||
it.exoPlayerPool.create(applicationContext)
|
||||
}
|
||||
} else {
|
||||
poolWithProxy?.let { return it }
|
||||
|
||||
@@ -116,7 +124,11 @@ class PlaybackService : MediaSessionService() {
|
||||
val videoCache = Amethyst.instance.videoCache
|
||||
val blossomServerResolver = Amethyst.instance.blossomResolver
|
||||
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it }
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver)
|
||||
.also {
|
||||
poolWithProxy = it
|
||||
it.exoPlayerPool.create(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,23 +173,27 @@ class PlaybackService : MediaSessionService() {
|
||||
return
|
||||
}
|
||||
|
||||
playing.forEachIndexed { idx, it ->
|
||||
playing.forEach {
|
||||
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
|
||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
playing.forEachIndexed { idx, it ->
|
||||
playing.forEach {
|
||||
if (it.session.player.isPlaying && it.session.player.volume > 0) {
|
||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
playing.forEachIndexed { idx, it ->
|
||||
// Falls through to the first muted-but-playing session. Earlier this loop missed
|
||||
// its return and called super.onUpdateNotification once per playing session,
|
||||
// hammering the notification system whenever multiple feed videos were preloading.
|
||||
playing.forEach {
|
||||
if (it.session.player.isPlaying) {
|
||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user