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