From 89607376ccc4ee168d7d46b3c1536f20a4821da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 14:51:09 +0000 Subject: [PATCH] feat(playback): surface unsupported codec errors with browser fallback ExoPlayer entered the ERROR state silently when a codec was missing or the container/format wasn't supported, leaving a blank video area with no recourse. Track the player error in MediaControllerState, render an overlay with the error code, and offer an "Open in browser" button so the user can fall back to the system browser for codecs the device can't decode. --- .../composable/MediaControllerState.kt | 7 + .../composable/RenderPlaybackError.kt | 122 ++++++++++++++++++ .../playback/composable/RenderVideoPlayer.kt | 8 ++ .../composable/WatchPlaybackErrors.kt | 67 ++++++++++ amethyst/src/main/res/values/strings.xml | 4 + 5 files changed, 208 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderPlaybackError.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt index 701ff382c7..bc447a22e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.service.playback.composable import android.graphics.Rect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.media3.common.PlaybackException import androidx.media3.common.Player import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -34,6 +37,10 @@ class MediaControllerState( val controller: Player, // visibility onscreen val visibility: VisibilityData = VisibilityData(), + // latest unrecoverable playback error, or null when playback is healthy. Set by the + // Player.Listener wired in WatchPlaybackErrors and read by RenderPlaybackError to show + // the codec-not-supported overlay with the "open in browser" fallback. + val playbackError: MutableState = mutableStateOf(null), ) { fun isPlaying() = controller.isPlaying 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 new file mode 100644 index 0000000000..8b8742c4e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderPlaybackError.kt @@ -0,0 +1,122 @@ +/* + * 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.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +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.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.Log + +/** + * Overlay shown on top of the video surface when the MediaController reports an unrecoverable + * playback error (codec init failure, unsupported format, decoder error, etc). + * + * Offers an "Open in browser" fallback that hands the URL to the system browser via + * [LocalUriHandler] so the user can still consume the content even if the on-device codec + * stack can't. + * + * Reads error state from [MediaControllerState.playbackError]; populated by [WatchPlaybackErrors]. + */ +@Composable +fun RenderPlaybackError( + controllerState: MediaControllerState, + videoUri: String, + modifier: Modifier = Modifier, +) { + val error by controllerState.playbackError + val current = error ?: return + + val uriHandler = LocalUriHandler.current + val errorCodeName = remember(current) { current.errorCodeName } + + Column( + modifier = + modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.75f)) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + 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.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) } + }, + ) { + 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)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index aa754148aa..125013c2b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -95,6 +95,8 @@ fun RenderVideoPlayer( val containerWidth = remember { intArrayOf(0) } val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) } + WatchPlaybackErrors(controllerState) + Box( modifier = borderModifier @@ -127,6 +129,12 @@ fun RenderVideoPlayer( shutter = {}, ) + RenderPlaybackError( + controllerState = controllerState, + videoUri = mediaItem.src.videoUri, + modifier = Modifier.align(Alignment.Center), + ) + AudioPlayingAnimation( controllerState, mediaItem.src.waveformData, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt new file mode 100644 index 0000000000..7a1c073a9b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt @@ -0,0 +1,67 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player + +/** + * Mirrors the MediaController's terminal-error state into [MediaControllerState.playbackError] + * so [RenderPlaybackError] can show the codec-not-supported overlay with a browser fallback. + * + * ExoPlayer enters the ERROR state silently on decoder init / decode / format failures; the + * ContentFrame then renders nothing and the user is left staring at a blank box. Surfacing the + * exception gives [RenderVideoPlayer] something to render and lets the user open the URL + * externally instead. + */ +@Composable +fun WatchPlaybackErrors(controllerState: MediaControllerState) { + val controller = controllerState.controller + val errorState = controllerState.playbackError + + DisposableEffect(controllerState) { + // Prime from the controller's current state — a warm-pool player may already be in ERROR + // when we attach, in which case onPlayerErrorChanged will not fire again until prepare(). + errorState.value = controller.playerError + + val listener = + object : Player.Listener { + override fun onPlayerErrorChanged(error: PlaybackException?) { + errorState.value = error + } + + override fun onPlaybackStateChanged(state: Int) { + // Any successful transition out of IDLE/ERROR (typically after a manual retry + // via controller.prepare()) clears the overlay so the player can render. + if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) { + if (errorState.value != null) errorState.value = null + } + } + } + + controller.addListener(listener) + onDispose { + controller.removeListener(listener) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index dec0899393..ce1b6759e0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1625,6 +1625,10 @@ Wallet %1$s Error opening signer app The signer app could not be found. Check if the app hasn\'t been uninstalled + + Can\'t play this video + The codec or format isn\'t supported on this device (%1$s). Try opening it in your browser. + Open in browser Sign request rejected Signer Application Rejected Make sure the signer application has authorized this transaction