Improve playback errors UI

This commit is contained in:
vfsfitvnm 2022-08-07 12:41:27 +02:00
parent e4e53bf056
commit 073a50b34c
4 changed files with 181 additions and 102 deletions

View file

@ -0,0 +1,9 @@
package it.vfsfitvnm.vimusic.service
import androidx.media3.common.PlaybackException
class PlayableFormatNotFoundException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR)
class UnplayableException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR)
class LoginRequiredException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR)

View file

@ -268,10 +268,17 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
maybeRecoverPlaybackError()
maybeNormalizeVolume()
maybeProcessRadio()
}
private fun maybeRecoverPlaybackError() {
if (player.playerError != null) {
player.prepare()
}
}
private fun maybeProcessRadio() {
radio?.let { radio ->
if (player.mediaItemCount - player.currentMediaItemIndex <= 3) {
@ -595,11 +602,9 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
}
format.url
} ?: throw PlaybackException(
"Couldn't find a playable audio format",
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
} ?: throw PlayableFormatNotFoundException()
"UNPLAYABLE" -> throw UnplayableException()
"LOGIN_REQUIRED" -> throw LoginRequiredException()
else -> throw PlaybackException(
status,
null,
@ -614,7 +619,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
.subrange(dataSpec.uriPositionOffset, chunkLength)
} ?: throw PlaybackException(
null,
null,
urlResult?.exceptionOrNull(),
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}

View file

@ -0,0 +1,75 @@
package it.vfsfitvnm.vimusic.ui.views.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
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.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.ui.styling.BlackColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.medium
@Composable
fun PlaybackError(
isDisplayed: Boolean,
messageProvider: () -> String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val (_, typography) = LocalAppearance.current
Box {
AnimatedVisibility(
visible = isDisplayed,
enter = fadeIn(),
exit = fadeOut(),
) {
Spacer(
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onDismiss()
}
)
}
.fillMaxSize()
.background(Color.Black.copy(0.8f))
)
}
AnimatedVisibility(
visible = isDisplayed,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
modifier = Modifier
.align(Alignment.TopCenter)
) {
BasicText(
text = remember { messageProvider() },
style = typography.xs.center.medium.color(BlackColorPalette.text),
modifier = Modifier
.background(Color.Black.copy(0.4f))
.padding(all = 8.dp)
.fillMaxWidth()
)
}
}
}

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -27,12 +26,16 @@ import coil.compose.AsyncImage
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
import it.vfsfitvnm.vimusic.ui.components.themed.LoadingOrError
import it.vfsfitvnm.vimusic.service.LoginRequiredException
import it.vfsfitvnm.vimusic.service.PlayableFormatNotFoundException
import it.vfsfitvnm.vimusic.service.UnplayableException
import it.vfsfitvnm.vimusic.ui.styling.Dimensions
import it.vfsfitvnm.vimusic.ui.styling.px
import it.vfsfitvnm.vimusic.utils.rememberError
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
import it.vfsfitvnm.vimusic.utils.thumbnail
import java.net.UnknownHostException
import java.nio.channels.UnresolvedAddressException
@ExperimentalAnimationApi
@Composable
@ -55,105 +58,92 @@ fun Thumbnail(
val error by rememberError(player)
if (error == null) {
AnimatedContent(
targetState = mediaItemIndex,
transitionSpec = {
val slideDirection =
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
AnimatedContent(
targetState = mediaItemIndex,
transitionSpec = {
val slideDirection =
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
(slideIntoContainer(slideDirection) + fadeIn() with
slideOutOfContainer(slideDirection) + fadeOut()).using(
SizeTransform(clip = false)
)
},
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
) { currentMediaItemIndex ->
val mediaItem = remember(currentMediaItemIndex) {
player.getMediaItemAt(currentMediaItemIndex)
}
Box(
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(thumbnailSizeDp)
) {
AsyncImage(
model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
onShowLyrics(true)
},
onLongPress = {
onShowStatsForNerds(true)
}
)
}
.fillMaxSize()
)
Lyrics(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingLyrics,
onDismiss = {
onShowLyrics(false)
},
onLyricsUpdate = { areSynchronized, mediaId, lyrics ->
if (areSynchronized) {
if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) {
if (mediaId == mediaItem.mediaId) {
Database.insert(mediaItem) { song ->
song.copy(synchronizedLyrics = lyrics)
}
}
}
} else {
if (Database.updateLyrics(mediaId, lyrics) == 0) {
if (mediaId == mediaItem.mediaId) {
Database.insert(mediaItem) { song ->
song.copy(lyrics = lyrics)
}
}
}
}
},
size = thumbnailSizeDp,
mediaMetadataProvider = mediaItem::mediaMetadata,
durationProvider = player::getDuration,
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
)
StatsForNerds(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingStatsForNerds,
onDismiss = {
onShowStatsForNerds(false)
},
modifier = Modifier
)
}
(slideIntoContainer(slideDirection) + fadeIn() with
slideOutOfContainer(slideDirection) + fadeOut()).using(
SizeTransform(clip = false)
)
},
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(1f)
) { currentMediaItemIndex ->
val mediaItem = remember(currentMediaItemIndex) {
player.getMediaItemAt(currentMediaItemIndex)
}
} else {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.padding(bottom = 32.dp)
.padding(horizontal = 32.dp)
modifier = Modifier
.clip(ThumbnailRoundness.shape)
.size(thumbnailSizeDp)
) {
LoadingOrError(
errorMessage = error?.javaClass?.canonicalName,
onRetry = {
player.playWhenReady = true
player.prepare()
}
) {}
AsyncImage(
model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = { onShowLyrics(true) },
onLongPress = { onShowStatsForNerds(true) }
)
}
.fillMaxSize()
)
Lyrics(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingLyrics && error == null,
onDismiss = { onShowLyrics(false) },
onLyricsUpdate = { areSynchronized, mediaId, lyrics ->
if (areSynchronized) {
if (Database.updateSynchronizedLyrics(mediaId, lyrics) == 0) {
if (mediaId == mediaItem.mediaId) {
Database.insert(mediaItem) { song ->
song.copy(synchronizedLyrics = lyrics)
}
}
}
} else {
if (Database.updateLyrics(mediaId, lyrics) == 0) {
if (mediaId == mediaItem.mediaId) {
Database.insert(mediaItem) { song ->
song.copy(lyrics = lyrics)
}
}
}
}
},
size = thumbnailSizeDp,
mediaMetadataProvider = mediaItem::mediaMetadata,
durationProvider = player::getDuration,
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
)
StatsForNerds(
mediaId = mediaItem.mediaId,
isDisplayed = isShowingStatsForNerds && error == null,
onDismiss = { onShowStatsForNerds(false) }
)
PlaybackError(
isDisplayed = error != null,
messageProvider = {
when (error?.cause?.cause) {
is UnresolvedAddressException, is UnknownHostException -> "A network error has occurred"
is PlayableFormatNotFoundException -> "Couldn't find a playable audio format"
is UnplayableException -> "The original video source of this song has been deleted"
is LoginRequiredException -> "This song cannot be played due to server restrictions"
else -> "An unknown playback error has occurred"
}
},
onDismiss = player::prepare
)
}
}
}