|
@@ -4,13 +4,18 @@ import androidx.compose.animation.ExperimentalAnimationApi
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.clickable
|
|
|
|
+import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.itemsIndexed
|
|
import androidx.compose.foundation.lazy.itemsIndexed
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import androidx.compose.foundation.text.BasicText
|
|
import androidx.compose.foundation.text.BasicText
|
|
-import androidx.compose.runtime.*
|
|
|
|
|
|
+import androidx.compose.material.ripple.rememberRipple
|
|
|
|
+import androidx.compose.runtime.Composable
|
|
|
|
+import androidx.compose.runtime.collectAsState
|
|
|
|
+import androidx.compose.runtime.getValue
|
|
|
|
+import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.alpha
|
|
import androidx.compose.ui.draw.alpha
|
|
@@ -28,8 +33,9 @@ import it.vfsfitvnm.route.RouteHandler
|
|
import it.vfsfitvnm.vimusic.Database
|
|
import it.vfsfitvnm.vimusic.Database
|
|
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
|
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
|
import it.vfsfitvnm.vimusic.R
|
|
import it.vfsfitvnm.vimusic.R
|
|
|
|
+import it.vfsfitvnm.vimusic.models.Artist
|
|
import it.vfsfitvnm.vimusic.models.DetailedSong
|
|
import it.vfsfitvnm.vimusic.models.DetailedSong
|
|
-import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
|
|
|
|
|
|
+import it.vfsfitvnm.vimusic.query
|
|
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
|
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
|
|
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
|
|
import it.vfsfitvnm.vimusic.ui.components.themed.InHistoryMediaItemMenu
|
|
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
|
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
|
|
@@ -37,11 +43,13 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
|
|
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
|
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
|
|
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
|
import it.vfsfitvnm.vimusic.ui.views.SongItem
|
|
import it.vfsfitvnm.vimusic.utils.*
|
|
import it.vfsfitvnm.vimusic.utils.*
|
|
-import it.vfsfitvnm.youtubemusic.Outcome
|
|
|
|
import it.vfsfitvnm.youtubemusic.YouTube
|
|
import it.vfsfitvnm.youtubemusic.YouTube
|
|
|
|
+import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Dispatchers
|
|
-import kotlinx.coroutines.flow.flowOf
|
|
|
|
-import kotlinx.coroutines.withContext
|
|
|
|
|
|
+import kotlinx.coroutines.flow.distinctUntilChanged
|
|
|
|
+import kotlinx.coroutines.flow.first
|
|
|
|
+import kotlinx.coroutines.flow.map
|
|
|
|
+import kotlinx.coroutines.runBlocking
|
|
|
|
|
|
|
|
|
|
@ExperimentalAnimationApi
|
|
@ExperimentalAnimationApi
|
|
@@ -51,16 +59,6 @@ fun ArtistScreen(
|
|
) {
|
|
) {
|
|
val lazyListState = rememberLazyListState()
|
|
val lazyListState = rememberLazyListState()
|
|
|
|
|
|
- var artist by remember {
|
|
|
|
- mutableStateOf<Outcome<YouTube.Artist>>(Outcome.Loading)
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- val onLoad = relaunchableEffect(Unit) {
|
|
|
|
- artist = withContext(Dispatchers.IO) {
|
|
|
|
- YouTube.artist(browseId)
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
val albumRoute = rememberPlaylistOrAlbumRoute()
|
|
val albumRoute = rememberPlaylistOrAlbumRoute()
|
|
val artistRoute = rememberArtistRoute()
|
|
val artistRoute = rememberArtistRoute()
|
|
|
|
|
|
@@ -84,6 +82,26 @@ fun ArtistScreen(
|
|
val colorPalette = LocalColorPalette.current
|
|
val colorPalette = LocalColorPalette.current
|
|
val typography = LocalTypography.current
|
|
val typography = LocalTypography.current
|
|
|
|
|
|
|
|
+ val artistResult by remember(browseId) {
|
|
|
|
+ Database.artist(browseId).map { artist ->
|
|
|
|
+ artist?.takeIf {
|
|
|
|
+ artist.shufflePlaylistId != null
|
|
|
|
+ }?.let(Result.Companion::success) ?: YouTube.artist(browseId)
|
|
|
|
+ .map { youtubeArtist ->
|
|
|
|
+ Artist(
|
|
|
|
+ id = browseId,
|
|
|
|
+ name = youtubeArtist.name,
|
|
|
|
+ thumbnailUrl = youtubeArtist.thumbnail?.url,
|
|
|
|
+ info = youtubeArtist.description,
|
|
|
|
+ shuffleVideoId = youtubeArtist.shuffleEndpoint?.videoId,
|
|
|
|
+ shufflePlaylistId = youtubeArtist.shuffleEndpoint?.playlistId,
|
|
|
|
+ radioVideoId = youtubeArtist.radioEndpoint?.videoId,
|
|
|
|
+ radioPlaylistId = youtubeArtist.radioEndpoint?.playlistId,
|
|
|
|
+ ).also(Database::update)
|
|
|
|
+ }
|
|
|
|
+ }.distinctUntilChanged()
|
|
|
|
+ }.collectAsState(initial = null, context = Dispatchers.IO)
|
|
|
|
+
|
|
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
|
val (thumbnailSizeDp, thumbnailSizePx) = remember {
|
|
density.run {
|
|
density.run {
|
|
192.dp to 192.dp.roundToPx()
|
|
192.dp to 192.dp.roundToPx()
|
|
@@ -127,18 +145,19 @@ fun ArtistScreen(
|
|
}
|
|
}
|
|
|
|
|
|
item {
|
|
item {
|
|
- OutcomeItem(
|
|
|
|
- outcome = artist,
|
|
|
|
- onRetry = onLoad,
|
|
|
|
- onLoading = {
|
|
|
|
- Loading()
|
|
|
|
- }
|
|
|
|
- ) { artist ->
|
|
|
|
|
|
+ artistResult?.getOrNull()?.let { artist ->
|
|
AsyncImage(
|
|
AsyncImage(
|
|
- model = artist.thumbnail?.size(thumbnailSizePx),
|
|
|
|
|
|
+ model = artist.thumbnailUrl?.thumbnail(thumbnailSizePx),
|
|
contentDescription = null,
|
|
contentDescription = null,
|
|
modifier = Modifier
|
|
modifier = Modifier
|
|
.clip(CircleShape)
|
|
.clip(CircleShape)
|
|
|
|
+ .clickable {
|
|
|
|
+ query {
|
|
|
|
+ runBlocking {
|
|
|
|
+ Database.artist(browseId).first()?.copy(shufflePlaylistId = null)?.let(Database::update)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
.size(thumbnailSizeDp)
|
|
.size(thumbnailSizeDp)
|
|
)
|
|
)
|
|
|
|
|
|
@@ -160,7 +179,12 @@ fun ArtistScreen(
|
|
colorFilter = ColorFilter.tint(colorPalette.text),
|
|
colorFilter = ColorFilter.tint(colorPalette.text),
|
|
modifier = Modifier
|
|
modifier = Modifier
|
|
.clickable {
|
|
.clickable {
|
|
- binder?.playRadio(artist.shuffleEndpoint)
|
|
|
|
|
|
+ binder?.playRadio(
|
|
|
|
+ NavigationEndpoint.Endpoint.Watch(
|
|
|
|
+ videoId = artist.shuffleVideoId,
|
|
|
|
+ playlistId = artist.shufflePlaylistId
|
|
|
|
+ )
|
|
|
|
+ )
|
|
}
|
|
}
|
|
.shadow(elevation = 2.dp, shape = CircleShape)
|
|
.shadow(elevation = 2.dp, shape = CircleShape)
|
|
.background(
|
|
.background(
|
|
@@ -177,7 +201,12 @@ fun ArtistScreen(
|
|
colorFilter = ColorFilter.tint(colorPalette.text),
|
|
colorFilter = ColorFilter.tint(colorPalette.text),
|
|
modifier = Modifier
|
|
modifier = Modifier
|
|
.clickable {
|
|
.clickable {
|
|
- binder?.playRadio(artist.radioEndpoint)
|
|
|
|
|
|
+ binder?.playRadio(
|
|
|
|
+ NavigationEndpoint.Endpoint.Watch(
|
|
|
|
+ videoId = artist.radioVideoId,
|
|
|
|
+ playlistId = artist.radioPlaylistId
|
|
|
|
+ )
|
|
|
|
+ )
|
|
}
|
|
}
|
|
.shadow(elevation = 2.dp, shape = CircleShape)
|
|
.shadow(elevation = 2.dp, shape = CircleShape)
|
|
.background(
|
|
.background(
|
|
@@ -188,9 +217,18 @@ fun ArtistScreen(
|
|
.size(20.dp)
|
|
.size(20.dp)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
-
|
|
|
|
-
|
|
|
|
- }
|
|
|
|
|
|
+ } ?: artistResult?.exceptionOrNull()?.let { throwable ->
|
|
|
|
+ LoadingOrError(
|
|
|
|
+ errorMessage = throwable.javaClass.canonicalName,
|
|
|
|
+ onRetry = {
|
|
|
|
+ query {
|
|
|
|
+ runBlocking {
|
|
|
|
+ Database.artist(browseId).first()?.let(Database::update)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ )
|
|
|
|
+ } ?: LoadingOrError()
|
|
}
|
|
}
|
|
|
|
|
|
item {
|
|
item {
|
|
@@ -219,7 +257,11 @@ fun ArtistScreen(
|
|
modifier = Modifier
|
|
modifier = Modifier
|
|
.clickable(enabled = songs.isNotEmpty()) {
|
|
.clickable(enabled = songs.isNotEmpty()) {
|
|
binder?.stopRadio()
|
|
binder?.stopRadio()
|
|
- binder?.player?.forcePlayFromBeginning(songs.shuffled().map(DetailedSong::asMediaItem))
|
|
|
|
|
|
+ binder?.player?.forcePlayFromBeginning(
|
|
|
|
+ songs
|
|
|
|
+ .shuffled()
|
|
|
|
+ .map(DetailedSong::asMediaItem)
|
|
|
|
+ )
|
|
}
|
|
}
|
|
.padding(horizontal = 8.dp, vertical = 8.dp)
|
|
.padding(horizontal = 8.dp, vertical = 8.dp)
|
|
.size(20.dp)
|
|
.size(20.dp)
|
|
@@ -237,7 +279,10 @@ fun ArtistScreen(
|
|
thumbnailSize = songThumbnailSizePx,
|
|
thumbnailSize = songThumbnailSizePx,
|
|
onClick = {
|
|
onClick = {
|
|
binder?.stopRadio()
|
|
binder?.stopRadio()
|
|
- binder?.player?.forcePlayAtIndex(songs.map(DetailedSong::asMediaItem), index)
|
|
|
|
|
|
+ binder?.player?.forcePlayAtIndex(
|
|
|
|
+ songs.map(DetailedSong::asMediaItem),
|
|
|
|
+ index
|
|
|
|
+ )
|
|
},
|
|
},
|
|
menuContent = {
|
|
menuContent = {
|
|
InHistoryMediaItemMenu(song = song)
|
|
InHistoryMediaItemMenu(song = song)
|
|
@@ -245,7 +290,7 @@ fun ArtistScreen(
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
- artist.valueOrNull?.description?.let { description ->
|
|
|
|
|
|
+ artistResult?.getOrNull()?.info?.let { description ->
|
|
item {
|
|
item {
|
|
Column(
|
|
Column(
|
|
modifier = Modifier
|
|
modifier = Modifier
|
|
@@ -272,33 +317,78 @@ fun ArtistScreen(
|
|
}
|
|
}
|
|
|
|
|
|
@Composable
|
|
@Composable
|
|
-private fun Loading() {
|
|
|
|
|
|
+private fun LoadingOrError(
|
|
|
|
+ errorMessage: String? = null,
|
|
|
|
+ onRetry: (() -> Unit)? = null
|
|
|
|
+) {
|
|
|
|
+ val typography = LocalTypography.current
|
|
val colorPalette = LocalColorPalette.current
|
|
val colorPalette = LocalColorPalette.current
|
|
|
|
|
|
- Column(
|
|
|
|
- horizontalAlignment = Alignment.CenterHorizontally,
|
|
|
|
- modifier = Modifier
|
|
|
|
- .shimmer()
|
|
|
|
- ) {
|
|
|
|
- Spacer(
|
|
|
|
|
|
+ Box {
|
|
|
|
+ Column(
|
|
|
|
+ horizontalAlignment = Alignment.CenterHorizontally,
|
|
modifier = Modifier
|
|
modifier = Modifier
|
|
- .background(color = colorPalette.darkGray, shape = CircleShape)
|
|
|
|
- .size(192.dp)
|
|
|
|
- )
|
|
|
|
-
|
|
|
|
- TextPlaceholder(
|
|
|
|
- modifier = Modifier
|
|
|
|
- .alpha(0.9f)
|
|
|
|
- .padding(vertical = 8.dp, horizontal = 16.dp)
|
|
|
|
- )
|
|
|
|
|
|
+ .alpha(if (errorMessage == null) 1f else 0f)
|
|
|
|
+ .shimmer()
|
|
|
|
+ ) {
|
|
|
|
+ Spacer(
|
|
|
|
+ modifier = Modifier
|
|
|
|
+ .background(color = colorPalette.darkGray, shape = CircleShape)
|
|
|
|
+ .size(192.dp)
|
|
|
|
+ )
|
|
|
|
|
|
- repeat(3) {
|
|
|
|
TextPlaceholder(
|
|
TextPlaceholder(
|
|
modifier = Modifier
|
|
modifier = Modifier
|
|
- .alpha(0.8f)
|
|
|
|
- .padding(horizontal = 16.dp)
|
|
|
|
|
|
+ .alpha(0.9f)
|
|
|
|
+ .padding(vertical = 8.dp, horizontal = 16.dp)
|
|
)
|
|
)
|
|
|
|
+
|
|
|
|
+ repeat(3) {
|
|
|
|
+ TextPlaceholder(
|
|
|
|
+ modifier = Modifier
|
|
|
|
+ .alpha(0.8f)
|
|
|
|
+ .padding(horizontal = 16.dp)
|
|
|
|
+ )
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ errorMessage?.let {
|
|
|
|
+ Column(
|
|
|
|
+ modifier = Modifier
|
|
|
|
+ .align(Alignment.Center)
|
|
|
|
+ .padding(horizontal = 16.dp, vertical = 16.dp)
|
|
|
|
+ .clickable(
|
|
|
|
+ interactionSource = remember { MutableInteractionSource() },
|
|
|
|
+ indication = rememberRipple(bounded = true),
|
|
|
|
+ enabled = onRetry != null,
|
|
|
|
+ onClick = onRetry ?: {}
|
|
|
|
+ )
|
|
|
|
+ .background(colorPalette.lightBackground)
|
|
|
|
+ .padding(horizontal = 16.dp, vertical = 16.dp)
|
|
|
|
+ ) {
|
|
|
|
+ Image(
|
|
|
|
+ painter = painterResource(R.drawable.alert_circle),
|
|
|
|
+ contentDescription = null,
|
|
|
|
+ colorFilter = ColorFilter.tint(colorPalette.red),
|
|
|
|
+ modifier = Modifier
|
|
|
|
+ .padding(bottom = 16.dp)
|
|
|
|
+ .size(24.dp)
|
|
|
|
+ )
|
|
|
|
+
|
|
|
|
+ BasicText(
|
|
|
|
+ text = onRetry?.let { "Tap to retry" } ?: "Error",
|
|
|
|
+ style = typography.xxs.semiBold,
|
|
|
|
+ modifier = Modifier
|
|
|
|
+ .padding(horizontal = 16.dp)
|
|
|
|
+ )
|
|
|
|
+
|
|
|
|
+ BasicText(
|
|
|
|
+ text = "An error has occurred:\n$errorMessage",
|
|
|
|
+ style = typography.xxs.secondary,
|
|
|
|
+ modifier = Modifier
|
|
|
|
+ .padding(horizontal = 16.dp)
|
|
|
|
+ )
|
|
|
|
+ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
-
|
|
|