Merge pull request #3064 from vitorpamplona/claude/sweet-maxwell-UMahG

Add playback error overlay with browser fallback for video codec failures
This commit is contained in:
Vitor Pamplona
2026-05-27 11:04:09 -04:00
committed by GitHub
5 changed files with 208 additions and 0 deletions
@@ -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<PlaybackException?> = mutableStateOf(null),
) {
fun isPlaying() = controller.isPlaying
@@ -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))
}
}
}
@@ -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,
@@ -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)
}
}
}
+4
View File
@@ -1625,6 +1625,10 @@
<string name="wallet_number">Wallet %1$s</string>
<string name="error_opening_external_signer">Error opening signer app</string>
<string name="error_opening_external_signer_description">The signer app could not be found. Check if the app hasn\'t been uninstalled</string>
<string name="error_video_playback_failed">Can\'t play this video</string>
<string name="error_video_playback_failed_description">The codec or format isn\'t supported on this device (%1$s). Try opening it in your browser.</string>
<string name="error_video_open_in_browser">Open in browser</string>
<string name="sign_request_rejected2">Sign request rejected</string>
<string name="sign_request_rejected">Signer Application Rejected</string>
<string name="sign_request_rejected_description">Make sure the signer application has authorized this transaction</string>