diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt new file mode 100644 index 0000000..7a846be --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlaybackExceptions.kt @@ -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) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt index 020dd29..23784fb 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/service/PlayerService.kt @@ -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 ) } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/PlaybackError.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/PlaybackError.kt new file mode 100644 index 0000000..9ace202 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/PlaybackError.kt @@ -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() + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt index ea3235c..83859b2 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/player/Thumbnail.kt @@ -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 + ) } } }