perf(video): reduce jitter and per-recomposition work in note video pipeline

Tightens the hot path that runs whenever a video appears inside a note
in the feed (RichText -> ZoomableContentView -> VideoView).

- VideoView: resolve aspect ratio once per (uri, dim), and prime
  MediaAspectRatioCache from the imeta dim tag so repeat appearances
  (PiP, dialog, list re-enter) don't have to wait for ExoPlayer's
  onVideoSizeChanged before reserving layout space.
- VideoView: key the manual "tap to show" toggle on videoUri so a
  recycled feed slot doesn't inherit stale state from the previous video.
- VideoViewInner: hoist proxyPortForVideo() into a remember(videoUri) —
  the result was being recomputed every recomposition only to be dropped
  by GetMediaItem's URI-keyed remember.
- RenderVideoPlayer: stop holding container size in compose state.
  The size is only read in onDoubleTap, so a non-state IntArray holder
  removes a recomposition of the whole player tree on every layout pass.
  Also memoize isLiveStreaming() so the .m3u8 substring scan doesn't run
  on every recomposition.
- RenderTopButtons: same isLiveStreaming() memoization.
- GetVideoController: switch the remaining non-lambda Log.d call to the
  lambda overload so the message string isn't formatted when the log
  level is filtered out.
This commit is contained in:
Claude
2026-04-26 03:46:49 +00:00
parent c24e676004
commit 8e60a79eaf
5 changed files with 32 additions and 13 deletions
@@ -52,7 +52,7 @@ fun GetVideoController(
if (BackgroundMedia.isPlaying()) { if (BackgroundMedia.isPlaying()) {
// There is a video playing, start this one on mute. // There is a video playing, start this one on mute.
state.controller.volume = 0f state.controller.volume = 0f
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying") Log.d("PlaybackService") { "OnEach Muted due to BackgroundMedia.isPlaying" }
} else { } else {
// There is no other video playing. Use the default mute state to // There is no other video playing. Use the default mute state to
// decide if sound is on or not. // decide if sound is on or not.
@@ -34,7 +34,6 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.ContentFrame
@@ -90,19 +89,22 @@ fun RenderVideoPlayer(
hasBlurhash: Boolean = false, hasBlurhash: Boolean = false,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val containerSize = remember { mutableStateOf(IntSize.Zero) } // Hold the container size in a non-state holder so layout passes don't trigger an
val isLive = isLiveStreaming(mediaItem.src.videoUri) // unnecessary recomposition of the whole player tree just to update a value that is only
// ever read inside the onDoubleTap callback below.
val containerWidth = remember { intArrayOf(0) }
val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) }
Box( Box(
modifier = modifier =
borderModifier borderModifier
.onSizeChanged { containerSize.value = it } .onSizeChanged { containerWidth[0] = it.width }
.pointerInput(isLive, controllerState) { .pointerInput(isLive, controllerState) {
detectTapGestures( detectTapGestures(
onTap = { controllerVisible.value = !controllerVisible.value }, onTap = { controllerVisible.value = !controllerVisible.value },
onDoubleTap = { offset -> onDoubleTap = { offset ->
if (!isLive) { if (!isLive) {
val isLeftSide = offset.x < containerSize.value.width / 2 val isLeftSide = offset.x < containerWidth[0] / 2
if (isLeftSide) { if (isLeftSide) {
controllerState.controller.seekBackward() controllerState.controller.seekBackward()
} else { } else {
@@ -105,15 +105,29 @@ fun VideoView(
thumbhash: String? = null, thumbhash: String? = null,
) { ) {
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback() val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
val automaticallyStartPlayback = remember { mutableStateOf(initialAutoStart) } // Reset the manual-show toggle when the video URI changes so a recycled feed slot
// doesn't inherit "tapped to show" state from a prior video.
val automaticallyStartPlayback = remember(videoUri) { mutableStateOf(initialAutoStart) }
// Once the video is being shown, only honor the user's autoplay preference when it was auto-loaded. // Once the video is being shown, only honor the user's autoplay preference when it was auto-loaded.
// If the user manually tapped the download button, they want it to play. // If the user manually tapped the download button, they want it to play.
val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value) val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value)
if (blurhash == null && thumbhash == null) { // Resolve the aspect ratio once per composition. Prime the URL-keyed cache from the imeta
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) // dim tag so the next time this video appears (PiP, dialog, list re-enter) the cache hits
// without waiting for ExoPlayer's onVideoSizeChanged.
val ratio =
remember(videoUri, dimensions) {
val fromDim = dimensions?.takeIf { it.hasSize() }
if (fromDim != null) {
MediaAspectRatioCache.add(videoUri, fromDim.width, fromDim.height)
fromDim.aspectRatio()
} else {
MediaAspectRatioCache.get(videoUri)
}
}
if (blurhash == null && thumbhash == null) {
val modifier = val modifier =
if (ratio != null && automaticallyStartPlayback.value) { if (ratio != null && automaticallyStartPlayback.value) {
Modifier.aspectRatio(ratio) Modifier.aspectRatio(ratio)
@@ -149,8 +163,6 @@ fun VideoView(
} }
} }
} else { } else {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val modifier = val modifier =
if (ratio != null) { if (ratio != null) {
Modifier.aspectRatio(ratio) Modifier.aspectRatio(ratio)
@@ -59,6 +59,11 @@ fun VideoViewInner(
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value } val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value }
// The proxy port is decided once per video URI; recomputing on every recomposition does
// pointless work (the result is anyway dropped because GetMediaItem.remember is keyed on the
// URI alone, so the cached MediaItemData is locked in on the first frame).
val proxyPort = remember(videoUri) { accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri) }
GetMediaItem( GetMediaItem(
videoUri = videoUri, videoUri = videoUri,
title = title, title = title,
@@ -67,7 +72,7 @@ fun VideoViewInner(
callbackUri = nostrUriCallback, callbackUri = nostrUriCallback,
mimeType = mimeType, mimeType = mimeType,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri), proxyPort = proxyPort,
keepPlaying = true, keepPlaying = true,
waveformData = waveform, waveformData = waveform,
isLiveStream = isLiveStream, isLiveStream = isLiveStream,
@@ -105,7 +105,7 @@ fun RenderTopButtons(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val isLive = isLiveStreaming(mediaData.videoUri) val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) }
val pipSupported = val pipSupported =
remember { remember {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)