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:
Claude
2026-04-26 04:01:59 +00:00
parent 8e60a79eaf
commit a11ba2e55d
3 changed files with 68 additions and 7 deletions
@@ -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()
}
}
@@ -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()
}
}
}
@@ -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
}
}
}