Improve playback errors UI
This commit is contained in:
@@ -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)
|
||||||
@@ -268,10 +268,17 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
|
maybeRecoverPlaybackError()
|
||||||
maybeNormalizeVolume()
|
maybeNormalizeVolume()
|
||||||
maybeProcessRadio()
|
maybeProcessRadio()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun maybeRecoverPlaybackError() {
|
||||||
|
if (player.playerError != null) {
|
||||||
|
player.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun maybeProcessRadio() {
|
private fun maybeProcessRadio() {
|
||||||
radio?.let { radio ->
|
radio?.let { radio ->
|
||||||
if (player.mediaItemCount - player.currentMediaItemIndex <= 3) {
|
if (player.mediaItemCount - player.currentMediaItemIndex <= 3) {
|
||||||
@@ -595,11 +602,9 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||||||
}
|
}
|
||||||
|
|
||||||
format.url
|
format.url
|
||||||
} ?: throw PlaybackException(
|
} ?: throw PlayableFormatNotFoundException()
|
||||||
"Couldn't find a playable audio format",
|
"UNPLAYABLE" -> throw UnplayableException()
|
||||||
null,
|
"LOGIN_REQUIRED" -> throw LoginRequiredException()
|
||||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
|
||||||
)
|
|
||||||
else -> throw PlaybackException(
|
else -> throw PlaybackException(
|
||||||
status,
|
status,
|
||||||
null,
|
null,
|
||||||
@@ -614,7 +619,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene
|
|||||||
.subrange(dataSpec.uriPositionOffset, chunkLength)
|
.subrange(dataSpec.uriPositionOffset, chunkLength)
|
||||||
} ?: throw PlaybackException(
|
} ?: throw PlaybackException(
|
||||||
null,
|
null,
|
||||||
null,
|
urlResult?.exceptionOrNull(),
|
||||||
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
PlaybackException.ERROR_CODE_REMOTE_ERROR
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -27,12 +26,16 @@ import coil.compose.AsyncImage
|
|||||||
import it.vfsfitvnm.vimusic.Database
|
import it.vfsfitvnm.vimusic.Database
|
||||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||||
import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness
|
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.Dimensions
|
||||||
import it.vfsfitvnm.vimusic.ui.styling.px
|
import it.vfsfitvnm.vimusic.ui.styling.px
|
||||||
import it.vfsfitvnm.vimusic.utils.rememberError
|
import it.vfsfitvnm.vimusic.utils.rememberError
|
||||||
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
|
import it.vfsfitvnm.vimusic.utils.rememberMediaItemIndex
|
||||||
import it.vfsfitvnm.vimusic.utils.thumbnail
|
import it.vfsfitvnm.vimusic.utils.thumbnail
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.nio.channels.UnresolvedAddressException
|
||||||
|
|
||||||
@ExperimentalAnimationApi
|
@ExperimentalAnimationApi
|
||||||
@Composable
|
@Composable
|
||||||
@@ -55,105 +58,92 @@ fun Thumbnail(
|
|||||||
|
|
||||||
val error by rememberError(player)
|
val error by rememberError(player)
|
||||||
|
|
||||||
if (error == null) {
|
AnimatedContent(
|
||||||
AnimatedContent(
|
targetState = mediaItemIndex,
|
||||||
targetState = mediaItemIndex,
|
transitionSpec = {
|
||||||
transitionSpec = {
|
val slideDirection =
|
||||||
val slideDirection =
|
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
||||||
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
|
|
||||||
|
|
||||||
(slideIntoContainer(slideDirection) + fadeIn() with
|
(slideIntoContainer(slideDirection) + fadeIn() with
|
||||||
slideOutOfContainer(slideDirection) + fadeOut()).using(
|
slideOutOfContainer(slideDirection) + fadeOut()).using(
|
||||||
SizeTransform(clip = false)
|
SizeTransform(clip = false)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
) { currentMediaItemIndex ->
|
) { currentMediaItemIndex ->
|
||||||
val mediaItem = remember(currentMediaItemIndex) {
|
val mediaItem = remember(currentMediaItemIndex) {
|
||||||
player.getMediaItemAt(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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
modifier = Modifier
|
||||||
modifier = modifier
|
.clip(ThumbnailRoundness.shape)
|
||||||
.padding(bottom = 32.dp)
|
|
||||||
.padding(horizontal = 32.dp)
|
|
||||||
.size(thumbnailSizeDp)
|
.size(thumbnailSizeDp)
|
||||||
) {
|
) {
|
||||||
LoadingOrError(
|
AsyncImage(
|
||||||
errorMessage = error?.javaClass?.canonicalName,
|
model = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx),
|
||||||
onRetry = {
|
contentDescription = null,
|
||||||
player.playWhenReady = true
|
contentScale = ContentScale.Crop,
|
||||||
player.prepare()
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user