mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
co-authored by
Claude Opus 4.8
parent
041a4c1c71
commit
edf30489d0
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
+16
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user