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:
+1
-1
@@ -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.
|
||||||
|
|||||||
+7
-5
@@ -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 {
|
||||||
|
|||||||
+17
-5
@@ -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)
|
||||||
|
|||||||
+6
-1
@@ -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,
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user