diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt index 0616a6efe4..c5b9acfee8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model import androidx.collection.LruCache +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf interface MutableMediaAspectRatioCache { fun get(url: String): Float? @@ -32,10 +34,27 @@ interface MutableMediaAspectRatioCache { ) } +/** + * Aspect ratios keyed by media URL, learned from imeta `dim` tags up front or from the decoder once + * a first frame lands. + * + * Entries are snapshot state, so a composable that calls [get] **during composition** recomposes + * when the real dimensions arrive later. That matters because players and image loaders only report + * size after the first frame decodes: a caller that sized itself off a plain cache miss would stay + * wrong for the whole visit and only look right the *next* time the media is opened. Note this only + * works for reads made in composition — a read from inside `remember { }` is cached by `remember` + * itself and won't pick the update up. + */ object MediaAspectRatioCache : MutableMediaAspectRatioCache { - val mediaAspectRatioCacheByUrl = LruCache(1000) + private val cache = LruCache>(1000) - override fun get(url: String): Float? = mediaAspectRatioCacheByUrl.get(url) + // get-then-put has to be atomic, so the compound op is guarded even though LruCache is itself + // thread-safe. A miss still stores a slot: that empty slot is what the caller observes until + // add() fills it in. + @Synchronized + private fun entry(url: String): MutableState = cache.get(url) ?: mutableStateOf(null).also { cache.put(url, it) } + + override fun get(url: String): Float? = entry(url).value override fun add( url: String, @@ -43,7 +62,7 @@ object MediaAspectRatioCache : MutableMediaAspectRatioCache { height: Int, ) { if (height > 1) { - mediaAspectRatioCacheByUrl.put(url, width.toFloat() / height.toFloat()) + entry(url).value = width.toFloat() / height.toFloat() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 45f72cf3fe..798166ddb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -124,6 +124,11 @@ fun VideoView( // DimensionTag uses reference equality, not structural. val dimW = dimensions?.width val dimH = dimensions?.height + // Deliberately snapshotted in a remember rather than observing MediaAspectRatioCache: when the + // ratio flips null -> known mid-playback this branch both adds an aspectRatio and emits an + // extra Spacer, and restructuring the children around a live AndroidView leaves the player's + // TextureView on a stale surface (the video redraws at native size in the corner). The + // enclosing box in ZoomableContentView is what sizes the player, and that one does observe. val ratio = remember(videoUri, dimW, dimH) { if (dimW != null && dimH != null && dimW > 0 && dimH > 0) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index a499c08102..e918d88d80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -136,6 +136,14 @@ import java.io.IOException // Allows time for receiving app to copy the file after user confirms share. private const val SHARED_VIDEO_CLEANUP_DELAY_MS = 120_000L +// Assumed shape of a video whose dimensions nobody has reported yet — no imeta `dim` and nothing +// cached, which is the norm for a NIP-53 live stream on its first play. Without a ratio the sizing +// modifier leaves height unconstrained, so the player stretches to whatever ceiling encloses it +// (300.dp on the live-stream screen) and letterboxes the real frame inside, leaving black bars top +// and bottom. Guessing the overwhelmingly common video shape puts the first layout in the right +// place; [MediaAspectRatioCache] then corrects anything unusual once the decoder reports its size. +private const val DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f + @Composable fun ZoomableContentView( content: BaseMediaContent, @@ -195,7 +203,7 @@ fun ZoomableContentView( } is MediaUrlVideo -> { - val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) + val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) ?: DEFAULT_VIDEO_ASPECT_RATIO val bridgedUrl = remember(content.url, useLocalBlossomBridge) { content.toCoilModel(useLocalBlossomBridge) @@ -209,7 +217,13 @@ fun ZoomableContentView( backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } }, ) { Box( - modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), + // The sizing modifier is repeated here because ContentWarningGate only applies + // the one it is handed when the content is actually sensitive — the common + // non-sensitive path emits content() bare. Without a height constraint of its + // own this box stretches to whatever ceiling encloses it and the player + // letterboxes the frame inside, which is what put black bars above and below + // live streams (their enclosure is StreamingHeaderModifier's 300.dp cap). + modifier = mediaSizingModifier(ratio, contentScale).then(boundsTrackingModifier), contentAlignment = Alignment.Center, ) { VideoView(