fix(video): keep the "open in browser" button alive in a short error box

This commit is contained in:
davotoula
2026-07-28 09:46:45 +02:00
parent 011d5f2fa5
commit 86d0434628
2 changed files with 236 additions and 42 deletions
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.R
import io.mockk.mockk
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The "open in browser" fallback is the only thing this overlay offers the user, so it has to
* survive whatever box the media layout hands it.
*
* [Column] measures children in declaration order against the remaining height, so the button —
* being last — is what starves when the content is taller than the box. A note in the feed is
* inset under the 55dp author column (screenWidth - 89dp), and with no imeta `dim` the media box
* is 16:9, which on a 411dp phone is 322dp wide and only 181dp tall. That was enough to squeeze
* the button down to 0.38dp — present in the tree, invisible and untappable on screen — while the
* same note opened in the thread (full bleed, screenWidth - 26dp, so a 217dp box) rendered it at
* 35.8dp.
*/
@RunWith(AndroidJUnit4::class)
class PlaybackErrorOverlayFitTest {
@get:Rule val rule = createComposeRule()
private val browserButtonLabel =
InstrumentationRegistry
.getInstrumentation()
.targetContext
.getString(R.string.error_video_open_in_browser)
/** A FilledTonalButton's natural height; anything much under this is a squeezed button. */
private val naturalButtonHeight = 40.dp
private fun renderInBox(
width: Dp,
height: Dp,
) {
rule.setContent {
Box(Modifier.width(width).height(height)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
}
rule.waitForIdle()
}
private fun assertButtonUsable(context: String) {
val button = rule.onNodeWithText(browserButtonLabel)
button.assertIsDisplayed()
val bounds = button.getUnclippedBoundsInRoot()
val height = bounds.bottom - bounds.top
assertTrue(
"$context: browser button collapsed to $height (natural is $naturalButtonHeight)",
height >= naturalButtonHeight - 2.dp,
)
}
@Test
fun buttonSurvivesTheFeedsSixteenByNineBox() {
renderInBox(width = 322.dp, height = 181.dp)
assertButtonUsable("feed 16:9")
}
@Test
fun buttonSurvivesTheThreadsSixteenByNineBox() {
renderInBox(width = 385.dp, height = 217.dp)
assertButtonUsable("thread 16:9")
}
@Test
fun buttonSurvivesAnUnusuallyShortBox() {
// A 3:1 banner-ish stream, or a narrow quote card: far less height than 16:9 gives.
renderInBox(width = 322.dp, height = 110.dp)
assertButtonUsable("short box")
}
@Test
fun descriptionIsDroppedRatherThanSlicedInHalf() {
// A weighted Text given less than one line's height draws it clipped through the middle,
// which looks broken. Under that much pressure it should not be emitted at all.
renderInBox(width = 322.dp, height = 110.dp)
rule
.onNodeWithText(
InstrumentationRegistry.getInstrumentation().targetContext.getString(
R.string.error_video_playback_failed_description,
"ERROR_CODE_PARSING_MANIFEST_MALFORMED",
),
).assertDoesNotExist()
}
@Test
fun titleStillShowsWhenRoomIsTight() {
renderInBox(width = 322.dp, height = 181.dp)
rule.onNodeWithText(InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.error_video_playback_failed)).assertIsDisplayed()
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -68,55 +70,100 @@ fun RenderPlaybackError(
val uriHandler = LocalUriHandler.current
val errorCodeName = remember(current) { current.errorCodeName }
Column(
BoxWithConstraints(
modifier =
modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.75f))
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
.background(Color.Black.copy(alpha = 0.75f)),
) {
Icon(
symbol = MaterialSymbols.VideocamOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Color.White,
)
// A Column measures its children against the height left over by the ones before them, so
// the button — last in the stack — is what collapses when the overlay is taller than its
// box. That is not hypothetical: the media box is 16:9 whenever the stream reports no
// dimensions, and in the feed a note is inset under the 55dp author column, leaving 322dp
// x 181dp on a normal phone. The full layout wants ~220dp, so the button was rendering
// 0.38dp tall — in the tree, and invisible on screen.
//
// Two things keep it alive. The description takes a weight, so it is the element that
// yields when space runs short rather than the button. And the icon, which is decorative
// and costs 60dp with its spacer, is dropped when even one line of description plus the
// button would not otherwise fit.
val hasRoomForIcon = maxHeight >= MIN_HEIGHT_FOR_ICON
val hasRoomForDescription = maxHeight >= MIN_HEIGHT_FOR_DESCRIPTION
val padding = if (maxHeight >= MIN_HEIGHT_FOR_FULL_PADDING) 16.dp else 8.dp
Spacer(Modifier.height(12.dp))
Text(
text = stringRes(R.string.error_video_playback_failed),
color = Color.White,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringRes(R.string.error_video_playback_failed_description, errorCodeName),
color = Color.White.copy(alpha = 0.85f),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
FilledTonalButton(
onClick = {
runCatching { uriHandler.openUri(videoUri) }
.onFailure { Log.w("RenderPlaybackError", "openUri failed for $videoUri", it) }
},
Column(
modifier = Modifier.fillMaxSize().padding(padding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
symbol = MaterialSymbols.OpenInBrowser,
contentDescription = null,
modifier = Modifier.size(18.dp),
if (hasRoomForIcon) {
Icon(
symbol = MaterialSymbols.VideocamOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Color.White,
)
Spacer(Modifier.height(12.dp))
}
Text(
text = stringRes(R.string.error_video_playback_failed),
color = Color.White,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.error_video_open_in_browser))
if (hasRoomForDescription) {
Spacer(Modifier.height(4.dp))
Text(
text = stringRes(R.string.error_video_playback_failed_description, errorCodeName),
color = Color.White.copy(alpha = 0.85f),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
Spacer(Modifier.height(16.dp))
FilledTonalButton(
onClick = {
runCatching { uriHandler.openUri(videoUri) }
.onFailure { Log.w("RenderPlaybackError", "openUri failed for $videoUri", it) }
},
) {
Icon(
symbol = MaterialSymbols.OpenInBrowser,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.error_video_open_in_browser))
}
}
}
}
/**
* Smallest overlay height that still fits the 48dp icon and its 12dp spacer on top of the title,
* one line of description, the 16dp gap and the 40dp button, inside 16dp of padding. Below this
* the icon is the first thing to go, because it is the only part carrying no information.
*/
private val MIN_HEIGHT_FOR_ICON = 190.dp
/**
* Below this the 16dp padding is itself competing with the button for space — a very wide, short
* video (a panorama, or anything past about 2.5:1) leaves barely more height than the title and
* button need. Halving the padding there buys the button its full height back.
*/
private val MIN_HEIGHT_FOR_FULL_PADDING = 150.dp
/**
* Below this the description cannot fit even one full line, and a weighted Text handed less than a
* line's height draws it sliced in half rather than dropping it. Hide it instead: the title still
* says what went wrong and the button still offers the way out.
*/
private val MIN_HEIGHT_FOR_DESCRIPTION = 120.dp