diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt index 702dd99..967937f 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -29,7 +29,7 @@ interface Database { fun insert(info: Artist) @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(info: Album) + fun insert(info: Album): Long @Insert(onConflict = OnConflictStrategy.ABORT) fun insert(playlist: Playlist): Long @@ -111,6 +111,21 @@ interface Database { @Update fun update(album: Album) + fun upsert(album: Album) { + if (insert(album) == -1L) { + update(album) + } + } + + @Update + fun update(songAlbumMap: SongAlbumMap) + + fun upsert(songAlbumMap: SongAlbumMap) { + if (insert(songAlbumMap) == -1L) { + update(songAlbumMap) + } + } + @Update fun update(songInPlaylist: SongInPlaylist) @@ -141,10 +156,10 @@ interface Database { @RewriteQueriesToDropUnusedColumns fun artistSongs(artistId: String): Flow> -// @Transaction -// @Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId ORDER BY Song.ROWID DESC") -// @RewriteQueriesToDropUnusedColumns -// fun albumSongs(albumId: String): Flow> + @Transaction + @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") + @RewriteQueriesToDropUnusedColumns + fun albumSongs(albumId: String): Flow> @Insert(onConflict = OnConflictStrategy.ABORT) fun insertQueue(queuedMediaItems: List) @@ -181,7 +196,7 @@ interface Database { AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), AutoMigration(from = 7, to = 8, spec = DatabaseInitializer.From7To8Migration::class), - AutoMigration(from = 9, to = 10), + AutoMigration(from = 9, to = 10) ], ) @TypeConverters(Converters::class) @@ -196,7 +211,6 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { if (!::Instance.isInitialized) { Instance = Room .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") -// .addMigrations(From8To9Migration()) .addMigrations(From8To9Migration(), From10To11Migration()) .build() } diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt index 9a9aeab..405a167 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Album.kt @@ -7,8 +7,8 @@ import androidx.room.PrimaryKey data class Album( @PrimaryKey val id: String, val title: String?, - val thumbnailUrl: String?, - val year: String?, - val authorsText: String?, - val shareUrl: String? + val thumbnailUrl: String? = null, + val year: String? = null, + val authorsText: String? = null, + val shareUrl: String? = null ) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt index 67a2733..7a454a6 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/DetailedSong.kt @@ -10,7 +10,8 @@ data class DetailedSong( @Relation( entity = SongAlbumMap::class, entityColumn = "songId", - parentColumn = "id" + parentColumn = "id", + projection = ["albumId"] ) val albumId: String?, @Relation( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt index 6ee5dde..25ff36c 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt @@ -11,7 +11,10 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.* +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.Modifier import androidx.compose.ui.draw.alpha @@ -27,28 +30,25 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.valentinilk.shimmer.shimmer import it.vfsfitvnm.route.RouteHandler -import it.vfsfitvnm.vimusic.* +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder import it.vfsfitvnm.vimusic.R import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness import it.vfsfitvnm.vimusic.models.Album -import it.vfsfitvnm.vimusic.models.Playlist -import it.vfsfitvnm.vimusic.models.SongInPlaylist +import it.vfsfitvnm.vimusic.models.DetailedSong +import it.vfsfitvnm.vimusic.models.SongAlbumMap +import it.vfsfitvnm.vimusic.query import it.vfsfitvnm.vimusic.ui.components.LocalMenuState -import it.vfsfitvnm.vimusic.ui.components.OutcomeItem import it.vfsfitvnm.vimusic.ui.components.TopAppBar import it.vfsfitvnm.vimusic.ui.components.themed.* import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette import it.vfsfitvnm.vimusic.ui.styling.LocalTypography import it.vfsfitvnm.vimusic.ui.views.SongItem import it.vfsfitvnm.vimusic.utils.* -import it.vfsfitvnm.youtubemusic.Outcome import it.vfsfitvnm.youtubemusic.YouTube import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext @ExperimentalAnimationApi @@ -71,13 +71,25 @@ fun AlbumScreen( year = youtubeAlbum.year, authorsText = youtubeAlbum.authors?.joinToString("") { it.name }, shareUrl = youtubeAlbum.url - ).also(Database::update) + ).also(Database::upsert).also { + youtubeAlbum.items?.forEachIndexed { position, albumItem -> + albumItem.toMediaItem(browseId, youtubeAlbum)?.let(Database::insert)?.let { song -> + Database.upsert( + SongAlbumMap( + songId = song.id, + albumId = browseId, + position = position + ) + ) + } + } + } } }.distinctUntilChanged() }.collectAsState(initial = null, context = Dispatchers.IO) val songs by remember(browseId) { - Database.artistSongs(browseId) + Database.albumSongs(browseId) }.collectAsState(initial = emptyList(), context = Dispatchers.IO) val albumRoute = rememberAlbumRoute() @@ -111,12 +123,6 @@ fun AlbumScreen( } } - val (songThumbnailSizeDp, songThumbnailSizePx) = remember { - density.run { - 54.dp to 54.dp.roundToPx() - } - } - LazyColumn( state = lazyListState, contentPadding = PaddingValues(bottom = 72.dp), @@ -155,19 +161,9 @@ fun AlbumScreen( text = "Enqueue", onClick = { menuState.hide() - albumResult - ?.getOrNull() - ?.let { album -> -// album.items -// ?.mapNotNull { song -> -// song.toMediaItem(browseId, album) -// } -// ?.let { mediaItems -> -// binder?.player?.enqueue( -// mediaItems -// ) -// } - } + binder?.player?.enqueue( + songs.map(DetailedSong::asMediaItem) + ) } ) @@ -259,16 +255,9 @@ fun AlbumScreen( modifier = Modifier .clickable { binder?.stopRadio() -// playlistOrAlbum.items -// ?.shuffled() -// ?.mapNotNull { song -> -// song.toMediaItem(browseId, playlistOrAlbum) -// } -// ?.let { mediaItems -> -// binder?.player?.forcePlayFromBeginning( -// mediaItems -// ) -// } + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(DetailedSong::asMediaItem) + ) } .shadow(elevation = 2.dp, shape = CircleShape) .background( @@ -286,15 +275,9 @@ fun AlbumScreen( modifier = Modifier .clickable { binder?.stopRadio() -// playlistOrAlbum.items -// ?.mapNotNull { song -> -// song.toMediaItem(browseId, playlistOrAlbum) -// } -// ?.let { mediaItems -> -// binder?.player?.forcePlayFromBeginning( -// mediaItems -// ) -// } + binder?.player?.forcePlayFromBeginning( + songs.map(DetailedSong::asMediaItem) + ) } .shadow(elevation = 2.dp, shape = CircleShape) .background( @@ -308,405 +291,36 @@ fun AlbumScreen( } } } ?: albumResult?.exceptionOrNull()?.let { throwable -> - LoadingOrError( - errorMessage = throwable.javaClass.canonicalName, - onRetry = { - query { - runBlocking { - Database.album(browseId).first()?.let(Database::update) - } - } - } - ) - } ?: Loading() - } - -// itemsIndexed( -// items = playlistOrAlbum.valueOrNull?.items ?: emptyList(), -// contentType = { _, song -> song } -// ) { index, song -> -// SongItem( -// title = song.info.name, -// authors = (song.authors ?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name }, -// durationText = song.durationText, -// onClick = { -// binder?.stopRadio() -// playlistOrAlbum.valueOrNull?.items?.mapNotNull { song -> -// song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!) -// }?.let { mediaItems -> -// binder?.player?.forcePlayAtIndex(mediaItems, index) -// } -// }, -// startContent = { -// if (song.thumbnail == null) { -// BasicText( -// text = "${index + 1}", -// style = typography.xs.secondary.bold.center, -// maxLines = 1, -// overflow = TextOverflow.Ellipsis, -// modifier = Modifier -// .width(36.dp) -// ) -// } else { -// AsyncImage( -// model = song.thumbnail!!.size(songThumbnailSizePx), -// contentDescription = null, -// contentScale = ContentScale.Crop, -// modifier = Modifier -// .clip(ThumbnailRoundness.shape) -// .size(songThumbnailSizeDp) -// ) -// } -// }, -// menuContent = { -// NonQueuedMediaItemMenu( -// mediaItem = song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!) -// ?: return@SongItem, -// onDismiss = menuState::hide, -// ) -// } -// ) -// } - } - } - } -} - - -@ExperimentalAnimationApi -@Composable -fun PlaylistScreen( - browseId: String, -) { - val lazyListState = rememberLazyListState() - - val albumRoute = rememberAlbumRoute() - val artistRoute = rememberArtistRoute() - - RouteHandler(listenToGlobalEmitter = true) { - albumRoute { browseId -> - AlbumScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } - - artistRoute { browseId -> - ArtistScreen( - browseId = browseId ?: error("browseId cannot be null") - ) - } - - host { - val context = LocalContext.current - val density = LocalDensity.current - val binder = LocalPlayerServiceBinder.current - - val colorPalette = LocalColorPalette.current - val typography = LocalTypography.current - val menuState = LocalMenuState.current - - val (thumbnailSizeDp, thumbnailSizePx) = remember { - density.run { - 128.dp to 128.dp.roundToPx() - } - } - - val (songThumbnailSizeDp, songThumbnailSizePx) = remember { - density.run { - 54.dp to 54.dp.roundToPx() - } - } - - var playlistOrAlbum by remember { - mutableStateOf>(Outcome.Loading) - } - - val onLoad = relaunchableEffect(Unit) { - playlistOrAlbum = withContext(Dispatchers.IO) { - YouTube.playlistOrAlbum2(browseId) - } - } - - LazyColumn( - state = lazyListState, - contentPadding = PaddingValues(bottom = 72.dp), - modifier = Modifier - .background(colorPalette.background) - .fillMaxSize() - ) { - item { - TopAppBar( - modifier = Modifier - .height(52.dp) - ) { - Image( - painter = painterResource(R.drawable.chevron_back), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(onClick = pop) - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(24.dp) - ) - - Image( - painter = painterResource(R.drawable.ellipsis_horizontal), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - menuState.display { - Menu { - MenuCloseButton(onClick = menuState::hide) - - MenuEntry( - icon = R.drawable.time, - text = "Enqueue", - onClick = { - menuState.hide() - playlistOrAlbum.valueOrNull?.let { album -> - album.items - ?.mapNotNull { song -> - song.toMediaItem(browseId, album) - } - ?.let { mediaItems -> - binder?.player?.enqueue( - mediaItems - ) - } - } - } - ) - - MenuEntry( - icon = R.drawable.list, - text = "Import as playlist", - onClick = { - menuState.hide() - - playlistOrAlbum.valueOrNull?.let { album -> - transaction { - val playlistId = - Database.insert( - Playlist( - name = album.title - ?: "Unknown" - ) - ) - - album.items?.forEachIndexed { index, song -> - song - .toMediaItem(browseId, album) - ?.let { mediaItem -> - Database.insert(mediaItem) - - Database.insert( - SongInPlaylist( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = index - ) - ) - } - } - } - } - } - ) - - MenuEntry( - icon = R.drawable.share_social, - text = "Share", - onClick = { - menuState.hide() - - (playlistOrAlbum.valueOrNull?.url - ?: "https://music.youtube.com/playlist?list=${ - browseId.removePrefix( - "VL" - ) - }").let { url -> - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - - context.startActivity( - Intent.createChooser( - sendIntent, - null - ) - ) - } - } - ) - } - } - } - .padding(horizontal = 16.dp, vertical = 8.dp) - .size(24.dp) - ) - } - } - - item { - OutcomeItem( - outcome = playlistOrAlbum, - onRetry = onLoad, - onLoading = { - Loading() - } - ) { playlistOrAlbum -> - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Max) - .padding(vertical = 8.dp, horizontal = 16.dp) - .padding(bottom = 16.dp) - ) { - AsyncImage( - model = playlistOrAlbum.thumbnail?.size(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(thumbnailSizeDp) - ) - - Column( - verticalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxSize() - ) { - Column { - BasicText( - text = playlistOrAlbum.title ?: "Unknown", - style = typography.m.semiBold - ) - - BasicText( - text = buildString { - val authors = playlistOrAlbum.authors?.joinToString("") { it.name } - append(authors) - if (authors?.isNotEmpty() == true && playlistOrAlbum.year != null) { - append(" • ") - } - append(playlistOrAlbum.year) - }, - style = typography.xs.secondary.semiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .align(Alignment.End) - .padding(horizontal = 16.dp) - ) { - Image( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - binder?.stopRadio() - playlistOrAlbum.items - ?.shuffled() - ?.mapNotNull { song -> - song.toMediaItem(browseId, playlistOrAlbum) - } - ?.let { mediaItems -> - binder?.player?.forcePlayFromBeginning( - mediaItems - ) - } - } - .shadow(elevation = 2.dp, shape = CircleShape) - .background( - color = colorPalette.elevatedBackground, - shape = CircleShape - ) - .padding(horizontal = 16.dp, vertical = 16.dp) - .size(20.dp) - ) - - Image( - painter = painterResource(R.drawable.play), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - binder?.stopRadio() - playlistOrAlbum.items - ?.mapNotNull { song -> - song.toMediaItem(browseId, playlistOrAlbum) - } - ?.let { mediaItems -> - binder?.player?.forcePlayFromBeginning( - mediaItems - ) - } - } - .shadow(elevation = 2.dp, shape = CircleShape) - .background( - color = colorPalette.elevatedBackground, - shape = CircleShape - ) - .padding(horizontal = 16.dp, vertical = 16.dp) - .size(20.dp) - ) - } - } - } - } + LoadingOrError(errorMessage = throwable.javaClass.canonicalName) + } ?: LoadingOrError() } itemsIndexed( - items = playlistOrAlbum.valueOrNull?.items ?: emptyList(), + items = songs, + key = { _, song -> song.song.id }, contentType = { _, song -> song } ) { index, song -> SongItem( - title = song.info.name, - authors = (song.authors ?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name }, - durationText = song.durationText, + title = song.song.title, + authors = song.song.artistsText ?: albumResult?.getOrNull()?.authorsText, + durationText = song.song.durationText, onClick = { binder?.stopRadio() - playlistOrAlbum.valueOrNull?.items?.mapNotNull { song -> - song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!) - }?.let { mediaItems -> - binder?.player?.forcePlayAtIndex(mediaItems, index) - } + binder?.player?.forcePlayAtIndex(songs.map(DetailedSong::asMediaItem), index) }, startContent = { - if (song.thumbnail == null) { - BasicText( - text = "${index + 1}", - style = typography.xs.secondary.bold.center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .width(36.dp) - ) - } else { - AsyncImage( - model = song.thumbnail!!.size(songThumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(ThumbnailRoundness.shape) - .size(songThumbnailSizeDp) - ) - } + BasicText( + text = "${index + 1}", + style = typography.xs.secondary.bold.center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(36.dp) + ) }, menuContent = { NonQueuedMediaItemMenu( - mediaItem = song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!) - ?: return@SongItem, + mediaItem = song.asMediaItem, onDismiss = menuState::hide, ) } @@ -717,79 +331,6 @@ fun PlaylistScreen( } } -@Composable -private fun Loading() { - val colorPalette = LocalColorPalette.current - - Column( - modifier = Modifier - .shimmer() - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .height(IntrinsicSize.Max) - .padding(vertical = 8.dp, horizontal = 16.dp) - .padding(bottom = 16.dp) - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.darkGray, shape = ThumbnailRoundness.shape) - .size(128.dp) - ) - - Column( - verticalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxHeight() - ) { - Column { - TextPlaceholder() - - TextPlaceholder( - modifier = Modifier - .alpha(0.7f) - ) - } - } - } - - repeat(3) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .alpha(0.6f - it * 0.1f) - .height(54.dp) - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 16.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(36.dp) - ) { - Spacer( - modifier = Modifier - .size(8.dp) - .background(color = colorPalette.darkGray, shape = CircleShape) - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - TextPlaceholder() - - TextPlaceholder( - modifier = Modifier - .alpha(0.7f) - ) - } - } - } - } -} @Composable private fun LoadingOrError( @@ -832,41 +373,6 @@ private fun LoadingOrError( } } } - - repeat(3) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .alpha(0.6f - it * 0.1f) - .height(54.dp) - .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 16.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(36.dp) - ) { - Spacer( - modifier = Modifier - .size(8.dp) - .background(color = colorPalette.darkGray, shape = CircleShape) - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - TextPlaceholder() - - TextPlaceholder( - modifier = Modifier - .alpha(0.7f) - ) - } - } - } } errorMessage?.let { diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt new file mode 100644 index 0000000..6a90296 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt @@ -0,0 +1,474 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import android.content.Intent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.ThumbnailRoundness +import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.models.SongInPlaylist +import it.vfsfitvnm.vimusic.transaction +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.OutcomeItem +import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.components.themed.* +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.* +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + + +@ExperimentalAnimationApi +@Composable +fun PlaylistScreen( + browseId: String, +) { + val lazyListState = rememberLazyListState() + + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + RouteHandler(listenToGlobalEmitter = true) { + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val context = LocalContext.current + val density = LocalDensity.current + val binder = LocalPlayerServiceBinder.current + + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + val menuState = LocalMenuState.current + + val (thumbnailSizeDp, thumbnailSizePx) = remember { + density.run { + 128.dp to 128.dp.roundToPx() + } + } + + val (songThumbnailSizeDp, songThumbnailSizePx) = remember { + density.run { + 54.dp to 54.dp.roundToPx() + } + } + + var playlistOrAlbum by remember { + mutableStateOf>(Outcome.Loading) + } + + val onLoad = relaunchableEffect(Unit) { + playlistOrAlbum = withContext(Dispatchers.IO) { + YouTube.playlistOrAlbum2(browseId) + } + } + + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues(bottom = 72.dp), + modifier = Modifier + .background(colorPalette.background) + .fillMaxSize() + ) { + item { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + Image( + painter = painterResource(R.drawable.chevron_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable(onClick = pop) + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + + Image( + painter = painterResource(R.drawable.ellipsis_horizontal), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + menuState.display { + Menu { + MenuCloseButton(onClick = menuState::hide) + + MenuEntry( + icon = R.drawable.time, + text = "Enqueue", + onClick = { + menuState.hide() + playlistOrAlbum.valueOrNull?.let { album -> + album.items + ?.mapNotNull { song -> + song.toMediaItem(browseId, album) + } + ?.let { mediaItems -> + binder?.player?.enqueue( + mediaItems + ) + } + } + } + ) + + MenuEntry( + icon = R.drawable.list, + text = "Import as playlist", + onClick = { + menuState.hide() + + playlistOrAlbum.valueOrNull?.let { album -> + transaction { + val playlistId = + Database.insert( + Playlist( + name = album.title + ?: "Unknown" + ) + ) + + album.items?.forEachIndexed { index, song -> + song + .toMediaItem(browseId, album) + ?.let { mediaItem -> + Database.insert(mediaItem) + + Database.insert( + SongInPlaylist( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + ) + ) + } + } + } + } + } + ) + + MenuEntry( + icon = R.drawable.share_social, + text = "Share", + onClick = { + menuState.hide() + + (playlistOrAlbum.valueOrNull?.url + ?: "https://music.youtube.com/playlist?list=${ + browseId.removePrefix( + "VL" + ) + }").let { url -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) + } + + context.startActivity( + Intent.createChooser( + sendIntent, + null + ) + ) + } + } + ) + } + } + } + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + } + } + + item { + OutcomeItem( + outcome = playlistOrAlbum, + onRetry = onLoad, + onLoading = { + Loading() + } + ) { playlistOrAlbum -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .padding(vertical = 8.dp, horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + AsyncImage( + model = playlistOrAlbum.thumbnail?.size(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(ThumbnailRoundness.shape) + .size(thumbnailSizeDp) + ) + + Column( + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxSize() + ) { + Column { + BasicText( + text = playlistOrAlbum.title ?: "Unknown", + style = typography.m.semiBold + ) + + BasicText( + text = buildString { + val authors = + playlistOrAlbum.authors?.joinToString("") { it.name } + append(authors) + if (authors?.isNotEmpty() == true && playlistOrAlbum.year != null) { + append(" • ") + } + append(playlistOrAlbum.year) + }, + style = typography.xs.secondary.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp) + ) { + Image( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + binder?.stopRadio() + playlistOrAlbum.items + ?.shuffled() + ?.mapNotNull { song -> + song.toMediaItem(browseId, playlistOrAlbum) + } + ?.let { mediaItems -> + binder?.player?.forcePlayFromBeginning( + mediaItems + ) + } + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background( + color = colorPalette.elevatedBackground, + shape = CircleShape + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + + Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + binder?.stopRadio() + playlistOrAlbum.items + ?.mapNotNull { song -> + song.toMediaItem(browseId, playlistOrAlbum) + } + ?.let { mediaItems -> + binder?.player?.forcePlayFromBeginning( + mediaItems + ) + } + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background( + color = colorPalette.elevatedBackground, + shape = CircleShape + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + } + } + } + } + } + + itemsIndexed( + items = playlistOrAlbum.valueOrNull?.items ?: emptyList(), + contentType = { _, song -> song } + ) { index, song -> + SongItem( + title = song.info.name, + authors = (song.authors + ?: playlistOrAlbum.valueOrNull?.authors)?.joinToString("") { it.name }, + durationText = song.durationText, + onClick = { + binder?.stopRadio() + playlistOrAlbum.valueOrNull?.items?.mapNotNull { song -> + song.toMediaItem(browseId, playlistOrAlbum.valueOrNull!!) + }?.let { mediaItems -> + binder?.player?.forcePlayAtIndex(mediaItems, index) + } + }, + startContent = { + if (song.thumbnail == null) { + BasicText( + text = "${index + 1}", + style = typography.xs.secondary.bold.center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(36.dp) + ) + } else { + AsyncImage( + model = song.thumbnail!!.size(songThumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(ThumbnailRoundness.shape) + .size(songThumbnailSizeDp) + ) + } + }, + menuContent = { + NonQueuedMediaItemMenu( + mediaItem = song.toMediaItem( + browseId, + playlistOrAlbum.valueOrNull!! + ) + ?: return@SongItem, + onDismiss = menuState::hide, + ) + } + ) + } + } + } + } +} + + + +@Composable +private fun Loading() { + val colorPalette = LocalColorPalette.current + + Column( + modifier = Modifier + .shimmer() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .height(IntrinsicSize.Max) + .padding(vertical = 8.dp, horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.darkGray, shape = ThumbnailRoundness.shape) + .size(128.dp) + ) + + Column( + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxHeight() + ) { + Column { + TextPlaceholder() + + TextPlaceholder( + modifier = Modifier + .alpha(0.7f) + ) + } + } + } + + repeat(3) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .alpha(0.6f - it * 0.1f) + .height(54.dp) + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + ) { + Spacer( + modifier = Modifier + .size(8.dp) + .background(color = colorPalette.darkGray, shape = CircleShape) + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + TextPlaceholder() + + TextPlaceholder( + modifier = Modifier + .alpha(0.7f) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt index 5e94a3e..20679d9 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt @@ -46,9 +46,9 @@ fun Database.insert(mediaItem: MediaItem): Song { authorsText = null, thumbnailUrl = null, shareUrl = null, - ).also(::insert) + ).also(::upsert) - insert( + upsert( SongAlbumMap( songId = song.id, albumId = albumId, diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt index 30f275b..3435004 100644 --- a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -690,7 +690,7 @@ object YouTube { } suspend fun browse2(browseId: String): Result { - return runCatching { + return runCatching { client.post("/youtubei/v1/browse") { contentType(ContentType.Application.Json) setBody(