diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt new file mode 100644 index 0000000000..01bb97a102 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/playback/composable/PlaybackErrorOverlayFitTest.kt @@ -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(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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderPlaybackError.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderPlaybackError.kt index 8b8742c4e6..e79f49068d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderPlaybackError.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderPlaybackError.kt @@ -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