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.annotation.OptIn
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||||
@@ -42,10 +43,35 @@ class ExoPlayerBuilder(
|
|||||||
.Builder(context)
|
.Builder(context)
|
||||||
.apply {
|
.apply {
|
||||||
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
|
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
|
||||||
|
setLoadControl(feedTunedLoadControl())
|
||||||
}.build()
|
}.build()
|
||||||
.apply {
|
.apply {
|
||||||
addListener(AspectRatioCacher(MediaAspectRatioCache))
|
addListener(AspectRatioCacher(MediaAspectRatioCache))
|
||||||
addListener(KeepVideosPlaying(this))
|
addListener(KeepVideosPlaying(this))
|
||||||
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
|
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.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class ExoPlayerPool(
|
class ExoPlayerPool(
|
||||||
@@ -54,9 +56,26 @@ class ExoPlayerPool(
|
|||||||
|
|
||||||
private val mutex = Mutex()
|
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) {
|
fun create(context: Context) {
|
||||||
while (playerPool.size < poolStartingSize) {
|
if (!warmupStarted.compareAndSet(false, true)) return
|
||||||
playerPool.offer(builder.build(context))
|
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
|
val blossomServerResolver = Amethyst.instance.blossomResolver
|
||||||
|
|
||||||
// creates new
|
// 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 {
|
} else {
|
||||||
poolWithProxy?.let { return it }
|
poolWithProxy?.let { return it }
|
||||||
|
|
||||||
@@ -116,7 +124,11 @@ class PlaybackService : MediaSessionService() {
|
|||||||
val videoCache = Amethyst.instance.videoCache
|
val videoCache = Amethyst.instance.videoCache
|
||||||
val blossomServerResolver = Amethyst.instance.blossomResolver
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playing.forEachIndexed { idx, it ->
|
playing.forEach {
|
||||||
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
|
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
|
||||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playing.forEachIndexed { idx, it ->
|
playing.forEach {
|
||||||
if (it.session.player.isPlaying && it.session.player.volume > 0) {
|
if (it.session.player.isPlaying && it.session.player.volume > 0) {
|
||||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||||
return
|
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) {
|
if (it.session.player.isPlaying) {
|
||||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user