diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 4ceb4497b..eaa31e31e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -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() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 4a32d8066..962ab5331 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -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() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 9b259e69c..f323e01bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -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 } } }