fix(video): size the player box so live streams stop rendering black bars

A live stream opened for the first time drew ~90px of black above and below
the picture. The video surface itself was correct 16:9; the box enclosing it
was not. Measured on a Pixel 9 emulator: container [0,274][1080,1062] (788px
= StreamingHeaderModifier's 300.dp cap) holding a TextureView of
[0,364][1080,972] (608px = 16:9 at 1080 wide), centred, so (788-608)/2 = 90px
per side.

Two independent causes, both needed fixing:

ContentWarningGate takes a `modifier` but drops it for anything not flagged
sensitive — the non-sensitive path emits `content()` bare. ZoomableContentView
was routing mediaSizingModifier() through exactly that parameter, so for
ordinary media the sizing never reached the layout at all. With no height
constraint the player stretched to whatever ceiling enclosed it and
letterboxed the frame inside. Apply the sizing to the inner Box, which is
always emitted.

Even applied, the ratio was unknown on a first play: a NIP-53 stream carries
no imeta `dim`, and MediaAspectRatioCache is only filled once the decoder
reports a size. The miss was frozen for the whole visit because the cache was
a plain LruCache read during composition, which triggers no recomposition when
it later fills — hence the bars vanishing only on a *second* visit to the same
stream. Back cache entries with snapshot state so a composition-time read
updates, and default an unknown video to 16:9 so the first layout already
lands in the right place.

VideoView keeps reading the cache inside remember() on purpose, with a comment
explaining why: making it observable there flips the ratio mid-playback, which
both adds an aspectRatio and emits an extra Spacer, and restructuring children
around a live AndroidView strands the player on a stale surface — the video
redraws at native size in the corner while layout bounds still look correct.

Verified on a cold cache: container and TextureView are both
[0,274][1080,882], against a header ending at 274 — zero gap. Feed image and
video layouts unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-22 11:24:30 -04:00
co-authored by Claude Opus 4.8
parent 041a4c1c71
commit edf30489d0
3 changed files with 43 additions and 5 deletions
@@ -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<String, Float>(1000)
private val cache = LruCache<String, MutableState<Float?>>(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<Float?> = cache.get(url) ?: mutableStateOf<Float?>(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()
}
}
}
@@ -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) {
@@ -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(