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()) {
// There is a video playing, start this one on mute.
state.controller.volume = 0f
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying")
Log.d("PlaybackService") { "OnEach Muted due to BackgroundMedia.isPlaying" }
} else {
// There is no other video playing. Use the default mute state to
// 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.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame
@@ -90,19 +89,22 @@ fun RenderVideoPlayer(
hasBlurhash: Boolean = false,
accountViewModel: AccountViewModel,
) {
val containerSize = remember { mutableStateOf(IntSize.Zero) }
val isLive = isLiveStreaming(mediaItem.src.videoUri)
// Hold the container size in a non-state holder so layout passes don't trigger an
// 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(
modifier =
borderModifier
.onSizeChanged { containerSize.value = it }
.onSizeChanged { containerWidth[0] = it.width }
.pointerInput(isLive, controllerState) {
detectTapGestures(
onTap = { controllerVisible.value = !controllerVisible.value },
onDoubleTap = { offset ->
if (!isLive) {
val isLeftSide = offset.x < containerSize.value.width / 2
val isLeftSide = offset.x < containerWidth[0] / 2
if (isLeftSide) {
controllerState.controller.seekBackward()
} else {
@@ -105,15 +105,29 @@ fun VideoView(
thumbhash: String? = null,
) {
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.
// If the user manually tapped the download button, they want it to play.
val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value)
if (blurhash == null && thumbhash == null) {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
// Resolve the aspect ratio once per composition. Prime the URL-keyed cache from the imeta
// 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 =
if (ratio != null && automaticallyStartPlayback.value) {
Modifier.aspectRatio(ratio)
@@ -149,8 +163,6 @@ fun VideoView(
}
}
} else {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val modifier =
if (ratio != null) {
Modifier.aspectRatio(ratio)
@@ -59,6 +59,11 @@ fun VideoViewInner(
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
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(
videoUri = videoUri,
title = title,
@@ -67,7 +72,7 @@ fun VideoViewInner(
callbackUri = nostrUriCallback,
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri),
proxyPort = proxyPort,
keepPlaying = true,
waveformData = waveform,
isLiveStream = isLiveStream,
@@ -105,7 +105,7 @@ fun RenderTopButtons(
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val isLive = isLiveStreaming(mediaData.videoUri)
val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) }
val pipSupported =
remember {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)