Merge pull request #2440 from vitorpamplona/claude/optimize-video-playback-yBvXm

Optimize video player mutex with single-winner cache
This commit is contained in:
Vitor Pamplona
2026-04-18 09:48:41 -04:00
committed by GitHub
4 changed files with 167 additions and 65 deletions
@@ -55,7 +55,7 @@ fun ControlWhenPlayerIsActive(
}
controller.play()
}
} else {
} else if (controller.isPlaying) {
// Pauses the video when it becomes invisible.
// Destroys the video later when it Disposes the element
// meanwhile if the user comes back, the position in the track is saved.
@@ -56,5 +56,20 @@ class MediaControllerState(
@Stable
class VisibilityData {
var bounds: Rect? = null
private set
var distanceToCenter: Float? = null
fun setBounds(
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
val current = bounds
if (current == null) {
bounds = Rect(left, top, right, bottom)
} else {
current.set(left, top, right, bottom)
}
}
}
@@ -30,7 +30,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView
@@ -38,8 +37,32 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import kotlin.math.abs
// This keeps the position of all visible videos in the current screen.
val trackingVideos = mutableSetOf<MediaControllerState>()
/**
* Tracking entry pairs the [MediaControllerState] with its active flag so the
* mutex can update the loser/winner directly without a secondary lookup.
*/
private class TrackedVideo(
val controller: MediaControllerState,
val active: MutableState<Boolean>,
)
// Process-wide list of currently registered videos. Compose callbacks run on
// the main thread so no synchronization is required. ArrayList beats HashSet
// here because the only iteration is the rare re-election after the winner
// disappears; adds/removes happen only when entering/leaving composition.
private val trackingVideos = ArrayList<TrackedVideo>()
// The closest video. Maintained as a single-winner cache so each scroll-frame
// position update is O(1) instead of O(N) per video (O(N^2) total).
private var winner: TrackedVideo? = null
// Cached result of view.getGlobalVisibleRect. The compose root rarely changes
// across a scroll burst; caching avoids N native calls per frame.
private val cachedRootRect = Rect()
private var cachedRootRectView: View? = null
private var cachedRootRectVisible: Boolean = false
private var cachedRootRectTimeNs: Long = 0L
private const val ROOT_RECT_CACHE_TTL_NS = 8_000_000L // ~half a frame at 60fps
/**
* This function selects only one Video to be active. The video that is closest to the center of the
@@ -53,84 +76,145 @@ fun VideoPlayerActiveMutex(
// Is the current video the closest to the center?
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) }
val tracked =
remember(controller, isClosestToTheCenterOfTheScreen) {
TrackedVideo(controller, isClosestToTheCenterOfTheScreen)
}
// Keep track of all available videos.
DisposableEffect(key1 = controller) {
trackingVideos.add(controller)
DisposableEffect(key1 = tracked) {
trackingVideos.add(tracked)
onDispose {
trackingVideos.remove(controller)
trackingVideos.remove(tracked)
tracked.controller.visibility.distanceToCenter = null
if (winner === tracked) {
winner = null
electNewWinner()
}
}
}
val view = LocalView.current
val videoModifier =
remember(controller) {
Modifier.fillMaxWidth().heightIn(min = 120.dp).onVisiblePositionChanges(view) { bounds, distanceToCenter ->
controller.visibility.bounds = bounds
controller.visibility.distanceToCenter = distanceToCenter
if (distanceToCenter != null) {
// finds out of the current video is the closest to the center.
var newActive = true
for (video in trackingVideos) {
val videoPos = video.visibility.distanceToCenter
if (videoPos != null && videoPos < distanceToCenter) {
newActive = false
break
}
}
// marks the current video active
if (isClosestToTheCenterOfTheScreen.value != newActive) {
isClosestToTheCenterOfTheScreen.value = newActive
}
} else {
// got out of screen, marks video as inactive
if (isClosestToTheCenterOfTheScreen.value) {
isClosestToTheCenterOfTheScreen.value = false
}
remember(tracked, view) {
Modifier.fillMaxWidth().heightIn(min = 120.dp).onGloballyPositioned { coordinates ->
if (!coordinates.isAttached) {
reportPosition(tracked, null)
return@onGloballyPositioned
}
val bounds = coordinates.boundsInWindow()
val left = bounds.left.toInt()
val top = bounds.top.toInt()
val right = bounds.right.toInt()
val bottom = bounds.bottom.toInt()
tracked.controller.visibility.setBounds(left, top, right, bottom)
reportPosition(tracked, distanceToCenter(view, left, top, right, bottom))
}
}
inner(videoModifier, isClosestToTheCenterOfTheScreen)
}
fun Modifier.onVisiblePositionChanges(
view: View,
onVisiblePosition: (Rect, Float?) -> Unit,
): Modifier =
onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view))
/**
* Single-winner update path. Called from onGloballyPositioned during scroll.
*
* Cost is O(1) for every position update except when the current winner becomes
* invisible — in that case we re-elect (O(N)). This brings the per-frame cost
* down from O(N^2) to O(N).
*/
private fun reportPosition(
tracked: TrackedVideo,
distanceToCenter: Float?,
) {
val previous = tracked.controller.visibility.distanceToCenter
if (previous == distanceToCenter) return // nothing changed
tracked.controller.visibility.distanceToCenter = distanceToCenter
if (distanceToCenter == null) {
// Became invisible. Only meaningful work if this video was the winner.
if (winner === tracked) {
tracked.active.value = false
winner = null
electNewWinner()
}
return
}
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(
bounds: Rect,
val current = winner
when {
current === tracked -> {
// Still the winner; distance was already updated above.
}
current == null -> {
// No winner yet — claim it.
winner = tracked
tracked.active.value = true
}
else -> {
val currentDistance = current.controller.visibility.distanceToCenter
if (currentDistance == null || distanceToCenter < currentDistance) {
// Dethrone the current winner.
current.active.value = false
winner = tracked
tracked.active.value = true
}
}
}
}
private fun electNewWinner() {
var bestDistance = Float.MAX_VALUE
var best: TrackedVideo? = null
val list = trackingVideos
for (i in list.indices) {
val tv = list[i]
val d = tv.controller.visibility.distanceToCenter ?: continue
if (d < bestDistance) {
bestDistance = d
best = tv
}
}
if (best != null) {
winner = best
best.active.value = true
}
}
/**
* Returns the cached visible-rect of the root view if it was computed within
* the last frame, otherwise refreshes the cache. The Rect is shared and must
* be treated as read-only by callers.
*/
private fun cachedGlobalVisibleRect(view: View): Rect? {
val now = System.nanoTime()
if (cachedRootRectView === view && (now - cachedRootRectTimeNs) < ROOT_RECT_CACHE_TTL_NS) {
return if (cachedRootRectVisible) cachedRootRect else null
}
cachedRootRectVisible = view.getGlobalVisibleRect(cachedRootRect)
cachedRootRectView = view
cachedRootRectTimeNs = now
return if (cachedRootRectVisible) cachedRootRect else null
}
private fun distanceToCenter(
view: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
): Float? {
if (!isAttached) return null
// Window relative bounds of our compose root view that are visible on the screen
val globalRootRect = Rect()
if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all.
if (right <= left || bottom <= top) return null
val rootRect = cachedGlobalVisibleRect(view) ?: return null
// Make sure we are completely in bounds.
if (top < rootRect.top || left < rootRect.left || right > rootRect.right || bottom > rootRect.bottom) {
return null
}
if (bounds.isEmpty) return null
// Make sure we are completely in bounds.
if (
bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left &&
bounds.right <= globalRootRect.right &&
bounds.bottom <= globalRootRect.bottom
) {
return abs(
((bounds.top + bounds.bottom) / 2.0f) - ((globalRootRect.top + globalRootRect.bottom) / 2.0f),
)
}
return null
return abs(((top + bottom) / 2.0f) - ((rootRect.top + rootRect.bottom) / 2.0f))
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import android.graphics.Rect
import androidx.annotation.OptIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -206,8 +205,12 @@ fun RenderVoicePlayer(
.height(100.dp)
.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt())
controllerState.visibility.bounds = boundRect
controllerState.visibility.setBounds(
bounds.left.toInt(),
bounds.top.toInt(),
bounds.right.toInt(),
bounds.bottom.toInt(),
)
}.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null, // to prevent the ripple from the tap