diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 8a574b73..c9780e9c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -29,6 +29,7 @@ import dev.krtirtho.spotube.core.server.MatchedTracksRepository import dev.krtirtho.spotube.core.server.StreamingUrlRepository import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.webview.WebViewController +import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.album.AlbumRepository import dev.krtirtho.spotube.modules.album.AlbumViewModel @@ -127,12 +128,13 @@ val sharedModules = module { viewModelOf(::SavedTracksViewModel) // Artist + singleOf(::ArtistRepository) viewModel { (artistId: String) -> ArtistViewModel( artistId = artistId, - pluginManager = get(), - savedTracksRepository = get(), + repository = get(), libraryRepository = get(), + savedTracksRepository = get(), audioPlayerQueue = get(), ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 44cb0044..603586fe 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -146,6 +146,7 @@ fun TrackList( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), simplified: Boolean = false, + scrollable: Boolean = true, ) { var filterQuery by rememberSaveable { mutableStateOf("") } var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } @@ -207,269 +208,307 @@ fun TrackList( val showAlbum = !isCompact val useDropdownForOptions = isDesktop && !isCompact - - Box(modifier = modifier.fillMaxWidth()) { - LazyColumn( - state = listState, + val trackCardContent: @Composable () -> Unit = { + Card( modifier = Modifier - .widthIn(max = 1280.dp) - .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 12.dp) + ) { + visibleTracks.forEachIndexed { displayedIndex, track -> + if (displayedIndex > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + TrackListRow( + index = displayedIndex + 1, + track = track, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + isCurrentTrack = track.id == currentTrackId, + isCurrentTrackPlaying = isCurrentTrackPlaying, + isSelectionMode = isSelectionMode, + isSelected = selectedTrackIds.contains(track.id), + onTrackClick = { + if (isSelectionMode) { + selectedTrackIds = + if (selectedTrackIds.contains(track.id)) { + selectedTrackIds - track.id + } else { + selectedTrackIds + track.id + } + } else { + onTrackClick(track) + } + }, + onLongClick = { + if (!useDropdownForOptions && !isSelectionMode) { + isSelectionMode = true + selectedTrackIds = setOf(track.id) + } else if (!useDropdownForOptions) { + selectedTrackForOptions = track + } + }, + onSelectionToggle = { checked -> + isSelectionMode = true + selectedTrackIds = if (checked) { + selectedTrackIds + track.id + } else { + selectedTrackIds - track.id + } + }, + onTrackOptionsAction = { action -> + onTrackOptionsAction( + track, + action + ) + }, + trackOptionsState = trackOptionsState(track), + onShowOptionsClick = { selectedTrackForOptions = track }, + onArtistClick = onArtistClick, + onAlbumClick = onAlbumClick, + onArtistsOverflowClick = { onArtistsOverflowClick(track) }, + ) + } + + if (isLoading && tracks.isEmpty()) { + repeat(ShimmerRowCount) { shimmerIndex -> + if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + ShimmerTrackListRow( + index = shimmerIndex + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + ) + } + } + + if (error != null) { + if (visibleTracks.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + TrackListFeedbackRow( + message = error, + isError = true, + ) + } + + if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { + TrackListFeedbackRow("No tracks found") + } + + if (isLoadingNextPage) { + if (visibleTracks.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + ShimmerTrackListRow( + index = visibleTracks.size + 1, + showIndex = showIndex, + showAlbum = showAlbum, + useDropdownForOptions = useDropdownForOptions, + ) + } + } + } + } + + val filterSortRow: @Composable () -> Unit = { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + .heightIn(max = 60.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), + ) { + if (showIndex || isSelectionMode) { + val allSelected = + visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size + val anySelected = selectedTrackIds.isNotEmpty() + val headerState = when { + allSelected -> CheckBoxState.SELECTED + anySelected -> CheckBoxState.INDETERMINATE + else -> CheckBoxState.UNSELECTED + } + CheckBox( + state = headerState, + onClick = { + if (allSelected) { + isSelectionMode = false + selectedTrackIds = emptySet() + } else { + isSelectionMode = true + selectedTrackIds = visibleTracks.map { it.id }.toSet() + } + }, + ) + } + + Box( + modifier = Modifier + .weight(1f) + ) { + TextField( + value = filterQuery, + onValueChange = { filterQuery = it }, + modifier = Modifier + .widthIn(max = 400.dp) + .align(Alignment.CenterEnd), + placeholder = { TextWithShimmer("Filter") }, + leadingIcon = { + Icon( + imageVector = Iconsax.IconsaxFilterSearch, + contentDescription = "Filter", + ) + }, + singleLine = true, + ) + } + + ButtonGroup { + Box( + contentAlignment = Alignment.Center, + ) { + AdaptiveDropdownBottomSheet( + items = TrackSortOption.entries.map { option -> + AdaptiveMenuItem( + label = option.label, + onClick = { sortBy = option }, + selected = sortBy == option, + ) + }, + trigger = { onClick -> + GroupIconButton( + onClick = onClick, + ) { + Icon( + imageVector = Iconsax.IconsaxSort, + contentDescription = "Sort", + ) + } + }, + ) + } + ButtonGroupDivider() + Box( + contentAlignment = Alignment.Center, + ) { + val targetTracks = if (selectedTrackIds.isNotEmpty()) { + visibleTracks.filter { selectedTrackIds.contains(it.id) } + } else { + visibleTracks + } + val trackCount = targetTracks.size + val isAll = + selectedTrackIds.isEmpty() || trackCount == visibleTracks.size + AdaptiveDropdownBottomSheet( + items = listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxDirectboxReceive, + label = if (isAll) "Download All" else "Download $trackCount", + onClick = { onBulkDownload(targetTracks) }, + ), + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", + onClick = { onBulkAddToQueue(targetTracks) }, + ), + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = if (isAll) "Play All Next" else "Play $trackCount Next", + onClick = { onBulkPlayNext(targetTracks) }, + ), + ), + trigger = { onClick -> + GroupIconButton( + onClick = onClick, + ) { + Icon( + imageVector = Iconsax.Iconsax3DotsMore, + contentDescription = "Bulk actions", + ) + } + }, + ) + } + } + } + } + + if (scrollable) { + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .widthIn(max = 1280.dp) + .align(Alignment.TopCenter) + .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + if (headerContent != null) { + item { + headerContent() + } + } + if (!simplified) { + item { filterSortRow() } + } + item(key = "track-card") { trackCardContent() } + if (footerContent != null) { + item { + footerContent() + } + } + } + + if (!useDropdownForOptions) { + selectedTrackForOptions?.let { track -> + TrackOptionsBottomSheet( + track = track, + state = trackOptionsState(track), + onDismiss = { selectedTrackForOptions = null }, + onAction = { action -> + onTrackOptionsAction(track, action) + selectedTrackForOptions = null + }, + onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + ) + } + } + VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) + } + } else { + Column( + modifier = modifier + .fillMaxWidth() .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), - contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(2.dp), ) { if (headerContent != null) { - item { - headerContent() - } + headerContent() } - if (!simplified) - item { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - .heightIn(max = 60.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), - ) { - if (showIndex || isSelectionMode) { - val allSelected = - visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size - val anySelected = selectedTrackIds.isNotEmpty() - val headerState = when { - allSelected -> CheckBoxState.SELECTED - anySelected -> CheckBoxState.INDETERMINATE - else -> CheckBoxState.UNSELECTED - } - CheckBox( - state = headerState, - onClick = { - if (allSelected) { - isSelectionMode = false - selectedTrackIds = emptySet() - } else { - isSelectionMode = true - selectedTrackIds = visibleTracks.map { it.id }.toSet() - } - }, - ) - } - - Box( - modifier = Modifier - .weight(1f) - ) { - TextField( - value = filterQuery, - onValueChange = { filterQuery = it }, - modifier = Modifier - .widthIn(max = 400.dp) - .align(Alignment.CenterEnd), - placeholder = { TextWithShimmer("Filter") }, - leadingIcon = { - Icon( - imageVector = Iconsax.IconsaxFilterSearch, - contentDescription = "Filter", - ) - }, - singleLine = true, - ) - } - - ButtonGroup { - Box( - contentAlignment = Alignment.Center, - ) { - AdaptiveDropdownBottomSheet( - items = TrackSortOption.entries.map { option -> - AdaptiveMenuItem( - label = option.label, - onClick = { sortBy = option }, - selected = sortBy == option, - ) - }, - trigger = { onClick -> - GroupIconButton( - onClick = onClick, - ) { - Icon( - imageVector = Iconsax.IconsaxSort, - contentDescription = "Sort", - ) - } - }, - ) - } - ButtonGroupDivider() - Box( - contentAlignment = Alignment.Center, - ) { - val targetTracks = if (selectedTrackIds.isNotEmpty()) { - visibleTracks.filter { selectedTrackIds.contains(it.id) } - } else { - visibleTracks - } - val trackCount = targetTracks.size - val isAll = - selectedTrackIds.isEmpty() || trackCount == visibleTracks.size - AdaptiveDropdownBottomSheet( - items = listOf( - AdaptiveMenuItem( - icon = Iconsax.IconsaxDirectboxReceive, - label = if (isAll) "Download All" else "Download $trackCount", - onClick = { onBulkDownload(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - ), - trigger = { onClick -> - GroupIconButton( - onClick = onClick, - ) { - Icon( - imageVector = Iconsax.Iconsax3DotsMore, - contentDescription = "Bulk actions", - ) - } - }, - ) - } - } - } - } - - item(key = "track-card") { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 12.dp) - ) { - visibleTracks.forEachIndexed { displayedIndex, track -> - if (displayedIndex > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - TrackListRow( - index = displayedIndex + 1, - track = track, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - isCurrentTrack = track.id == currentTrackId, - isCurrentTrackPlaying = isCurrentTrackPlaying, - isSelectionMode = isSelectionMode, - isSelected = selectedTrackIds.contains(track.id), - onTrackClick = { - if (isSelectionMode) { - selectedTrackIds = - if (selectedTrackIds.contains(track.id)) { - selectedTrackIds - track.id - } else { - selectedTrackIds + track.id - } - } else { - onTrackClick(track) - } - }, - onLongClick = { - if (!useDropdownForOptions && !isSelectionMode) { - isSelectionMode = true - selectedTrackIds = setOf(track.id) - } else if (!useDropdownForOptions) { - selectedTrackForOptions = track - } - }, - onSelectionToggle = { checked -> - isSelectionMode = true - selectedTrackIds = if (checked) { - selectedTrackIds + track.id - } else { - selectedTrackIds - track.id - } - }, - onTrackOptionsAction = { action -> - onTrackOptionsAction( - track, - action - ) - }, - trackOptionsState = trackOptionsState(track), - onShowOptionsClick = { selectedTrackForOptions = track }, - onArtistClick = onArtistClick, - onAlbumClick = onAlbumClick, - onArtistsOverflowClick = { onArtistsOverflowClick(track) }, - ) - } - - if (isLoading && tracks.isEmpty()) { - repeat(ShimmerRowCount) { shimmerIndex -> - if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - ShimmerTrackListRow( - index = shimmerIndex + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) - } - } - - if (error != null) { - if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - TrackListFeedbackRow( - message = error, - isError = true, - ) - } - - if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { - TrackListFeedbackRow("No tracks found") - } - - if (isLoadingNextPage) { - if (visibleTracks.isNotEmpty()) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - ) - } - ShimmerTrackListRow( - index = visibleTracks.size + 1, - showIndex = showIndex, - showAlbum = showAlbum, - useDropdownForOptions = useDropdownForOptions, - ) - } - } - } + if (!simplified) { + filterSortRow() } - + trackCardContent() if (footerContent != null) { - item { - footerContent() - } + footerContent() } } @@ -487,9 +526,7 @@ fun TrackList( ) } } - VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) } - } @OptIn(ExperimentalFoundationApi::class) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt new file mode 100644 index 00000000..f51cc187 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.artist + +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.modules.plugin.PluginManager +import io.github.reactivecircus.cache4k.Cache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class ArtistRepository( + private val pluginManager: PluginManager +) { + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + val plugin + get() = pluginManager.selectedMetadataPlugin.value + + private val overviewCache = Cache.Builder().build() + private val albumsCache = + Cache.Builder, PaginationResult>().build() + private val relatedArtistsCache = + Cache.Builder, PaginationResult>().build() + private val featuredPlaylistsCache = + Cache.Builder, PaginationResult>().build() + private val topTracksCache = Cache.Builder>().build() + + init { + scope.launch { + pluginManager.selectedMetadataPlugin + .filterNotNull() + .flatMapLatest { it.loggedInFlow } + .distinctUntilChanged() + .collect { + invalidateCaches() + } + } + } + + fun invalidateCaches() { + overviewCache.invalidateAll() + albumsCache.invalidateAll() + relatedArtistsCache.invalidateAll() + featuredPlaylistsCache.invalidateAll() + topTracksCache.invalidateAll() + } + + suspend fun artistOverview(artistId: String) = plugin?.let { plugin -> + overviewCache.get(artistId) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.artistOverview(artistId) + } + } + } + } + + suspend fun topTracks(artistId: String) = plugin?.let { plugin -> + topTracksCache.get(artistId) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.getArtistTop10Tracks(artistId) + } + } + } + } + + suspend fun albums( + artistId: String, + paginationStrategy: PaginationStrategy? = null + ) = plugin?.let { plugin -> + albumsCache.get( + artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20)) + ) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.getArtistAlbums(artistId, paginationStrategy) + } + } + } + } + + suspend fun relatedArtists( + artistId: String, + paginationStrategy: PaginationStrategy? = null + ) = plugin?.let { plugin -> + relatedArtistsCache.get( + artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20)) + ) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.relatedArtists(artistId, paginationStrategy) + } + } + } + } + + suspend fun featuredPlaylists( + artistId: String, + paginationStrategy: PaginationStrategy? = null + ) = plugin?.let { plugin -> + featuredPlaylistsCache.get( + artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20)) + ) { + pluginManager.withScope { + plugin.use { + metadataArtistAPI.featuredPlaylists(artistId, paginationStrategy) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 31fc21d9..4f2d5abd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -24,12 +24,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -45,9 +46,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -59,12 +62,12 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import compose.icons.FeatherIcons -import compose.icons.feathericons.Heart import compose.icons.feathericons.Play import compose.icons.feathericons.PlusSquare import compose.icons.feathericons.User import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue @@ -75,15 +78,19 @@ import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.component.ArtistCard +import dev.krtirtho.spotube.core.ui.component.PlaylistCard import dev.krtirtho.spotube.core.ui.component.TrackList import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard +import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel -import dev.krtirtho.spotube.modules.library.LibraryRepository +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -101,9 +108,7 @@ fun ArtistScreen(artistId: String) { ) val navigationCommands = koinInject() - val artistInfo by viewModel.artistInfo.collectAsStateWithLifecycle() - val topTracksState by viewModel.topTracks.collectAsStateWithLifecycle() - val albumsState by viewModel.albums.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() @@ -123,8 +128,6 @@ fun ArtistScreen(artistId: String) { ) } - val artist = artistInfo.artist - fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { viewModel.handleTrackOptionsAction(track, action) if (action is TrackOptionsAction.Share) { @@ -141,153 +144,285 @@ fun ArtistScreen(artistId: String) { Scaffold( topBar = { ApplicationMainBar() } ) { innerPadding -> - TrackList( - modifier = Modifier.padding(innerPadding), - headerContent = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(20.dp), + when (val currentState = state) { + is ArtistScreenState.Loading -> { + ArtistLoadingContent(innerPadding) + } + + is ArtistScreenState.Error -> { + ArtistErrorContent( + innerPadding = innerPadding, + message = currentState.message, + onRetry = { viewModel.refresh() } + ) + } + + is ArtistScreenState.Loaded -> { + val listState = rememberLazyListState() + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - ArtistHeaderCard( - artist = artist, - isSaved = savedArtistIds.contains(artistId), - isLoading = artistInfo.isLoading, - isSaving = artistInfo.isSaving, - error = artistInfo.error, - onFollowClick = viewModel::toggleSavedArtist, - ) + item { + ArtistHeaderCard( + artist = currentState.artist, + isSaved = savedArtistIds.contains(currentState.artist.id), + onFollowClick = viewModel::toggleSavedArtist, + ) + } - ArtistAlbumsSection( - albums = albumsState.items, - isLoading = albumsState.isLoading, - error = albumsState.error, - hasMore = albumsState.hasNextPage, - onViewAll = viewModel::loadNextAlbumsPage, - ) + item { + TopTracksHeader( + onPlay = viewModel::playTopTracks, + onAddToQueue = viewModel::addTopTracksToQueue, + ) + } - TopTracksHeader( - onPlay = viewModel::playTopTracks, - onAddToQueue = viewModel::addTopTracksToQueue, + item { + TrackList( + tracks = currentState.topTracks, + error = null, + hasMore = false, + isLoading = false, + isLoadingNextPage = false, + currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, + isCurrentTrackPlaying = playerState == PlayerState.PLAYING, + onTrackClick = viewModel::playTopTracksFromTrack, + onTrackOptionsAction = ::handleTrackOptionsAction, + trackOptionsState = ::getTrackOptionsState, + onArtistClick = { trackArtist -> + navigationCommands.navigateTo( + Routes.Artist( + trackArtist.id + ) + ) + }, + onAlbumClick = { album -> + navigationCommands.navigateTo( + Routes.Album( + album.id + ) + ) + }, + onLoadNextPage = {}, + simplified = true, + scrollable = false, + onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) }, + onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) }, + onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) }, + ) + } + + if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { + item { + AlbumsSection( + albums = currentState.albums, + onAlbumClick = { album -> + navigationCommands.navigateTo( + Routes.Album( + album.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreAlbums() }, + ) + } + } + + if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) { + item { + RelatedArtistsSection( + artists = currentState.relatedArtists, + onArtistClick = { artist -> + navigationCommands.navigateTo( + Routes.Artist( + artist.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreRelatedArtists() }, + ) + } + } + + if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) { + item { + FeaturedPlaylistsSection( + playlists = currentState.featuredPlaylists, + onPlaylistClick = { playlist -> + navigationCommands.navigateTo( + Routes.Playlist( + playlist.id + ) + ) + }, + onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }, + ) + } + } + } + } + } + } +} + +@Composable +private fun ArtistLoadingContent(innerPadding: PaddingValues) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + SkeletonTree(true) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Box( + modifier = Modifier + .size(200.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), ) } - }, - tracks = topTracksState.items, - error = topTracksState.error, - hasMore = false, - isLoading = topTracksState.isLoading && topTracksState.items.isEmpty(), - isLoadingNextPage = false, - currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, - isCurrentTrackPlaying = playerState == PlayerState.PLAYING, - onTrackClick = viewModel::playTopTracksFromTrack, - onTrackOptionsAction = ::handleTrackOptionsAction, - trackOptionsState = ::getTrackOptionsState, - onArtistClick = { trackArtist -> navigationCommands.navigateTo(Routes.Artist(trackArtist.id)) }, - onAlbumClick = { album -> navigationCommands.navigateTo(Routes.Album(album.id)) }, - onLoadNextPage = { }, - simplified = true, - onBulkDownload = { tracks -> - downloadsViewModel.downloadTracks(tracks) - }, - onBulkAddToQueue = { tracks -> - viewModel.addTracksToQueue(tracks) - }, - onBulkPlayNext = { tracks -> - viewModel.playTracksNext(tracks) - }, - ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(4) { + PlayableCard( + title = "Item Title", + subtitle = "Subtitle", + imageURL = "https://placehold.co/600x400", + ) + } + } + } + } + } + } +} + +@Composable +private fun ArtistErrorContent( + innerPadding: PaddingValues, + message: String, + onRetry: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Button(onClick = onRetry) { + Text("Retry") + } + } } } @Composable private fun ArtistHeaderCard( - artist: MetadataArtist.Detailed?, + artist: MetadataArtist.Detailed, isSaved: Boolean, - isLoading: Boolean, - isSaving: Boolean, - error: String?, onFollowClick: () -> Unit, ) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val isCompact = maxWidth < 600.dp - SkeletonTree(isLoading = isLoading) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(20.dp), - ) { - if (isCompact) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ArtistAvatar( - artist = artist, - size = 180.dp, - ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + shape = RoundedCornerShape(20.dp), + ) { + if (isCompact) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ArtistAvatar( + artist = artist, + size = 180.dp, + ) + ArtistMeta( + artist = artist, + isCompact = true, + ) + + ArtistHeaderActions( + isSaved = isSaved, + onFollowClick = onFollowClick, + ) + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.Top, + ) { + ArtistAvatar( + artist = artist, + size = 220.dp, + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { ArtistMeta( artist = artist, - isCompact = true, + isCompact = false, ) ArtistHeaderActions( isSaved = isSaved, - isSaving = isSaving, onFollowClick = onFollowClick, ) - - error?.let { - TextWithShimmer( - text = it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(20.dp), - verticalAlignment = Alignment.Top, - ) { - ArtistAvatar( - artist = artist, - size = 220.dp, - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - ArtistMeta( - artist = artist, - isCompact = false, - ) - - ArtistHeaderActions( - isSaved = isSaved, - isSaving = isSaving, - onFollowClick = onFollowClick, - ) - - error?.let { - TextWithShimmer( - text = it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - } } } } @@ -297,29 +432,28 @@ private fun ArtistHeaderCard( @Composable private fun ArtistAvatar( - artist: MetadataArtist.Detailed?, + artist: MetadataArtist.Detailed, size: androidx.compose.ui.unit.Dp, ) { Box( modifier = Modifier .size(size) .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainerHighest) - .shimmerApply(), + .background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center, ) { - val imageUrl = artist?.thumbnails?.firstOrNull()?.url.orEmpty() + val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() if (imageUrl.isNotBlank()) { AsyncImage( model = imageUrl, - contentDescription = artist?.name ?: "Artist", + contentDescription = artist.name, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) } else { Icon( imageVector = FeatherIcons.User, - contentDescription = artist?.name ?: "Artist", + contentDescription = artist.name, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(size * 0.4f), ) @@ -329,7 +463,7 @@ private fun ArtistAvatar( @Composable private fun ArtistMeta( - artist: MetadataArtist.Detailed?, + artist: MetadataArtist.Detailed, isCompact: Boolean, ) { Column( @@ -337,38 +471,36 @@ private fun ArtistMeta( horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, verticalArrangement = Arrangement.spacedBy(10.dp), ) { - TextWithShimmer( - text = artist?.name ?: "Loading artist...", + Text( + text = artist.name, style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.SemiBold, - textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, maxLines = 2, overflow = TextOverflow.Ellipsis, ) - artist?.let { - TextWithShimmer( - text = buildString { - append(formatFollowers(it.followersCount)) - if (it.genres.isNotEmpty()) { - append(" • ") - append(it.genres.joinToString(", ")) - } - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start, - ) - } + Text( + text = buildString { + append(formatFollowers(artist.followersCount)) + if (artist.genres.isNotEmpty()) { + append(" • ") + append(artist.genres.joinToString(", ")) + } + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, + ) - artist?.biography?.takeIf { it.isNotBlank() }?.let { - TextWithShimmer( + artist.biography?.takeIf { it.isNotBlank() }?.let { + Text( text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = if (isCompact) 4 else 6, overflow = TextOverflow.Ellipsis, - textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start, + textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, ) } } @@ -377,7 +509,6 @@ private fun ArtistMeta( @Composable private fun ArtistHeaderActions( isSaved: Boolean, - isSaving: Boolean, onFollowClick: () -> Unit, ) { Row( @@ -385,91 +516,12 @@ private fun ArtistHeaderActions( verticalAlignment = Alignment.CenterVertically, ) { if (isSaved) { - FilledTonalButton(onClick = onFollowClick, enabled = !isSaving) { - TextWithShimmer("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) + FilledTonalButton(onClick = onFollowClick) { + Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } else { - Button(onClick = onFollowClick, enabled = !isSaving) { - TextWithShimmer("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) - } - } - } -} - -@Composable -private fun ArtistAlbumsSection( - albums: List, - isLoading: Boolean, - error: String?, - hasMore: Boolean, - onViewAll: () -> Unit, -) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - TextWithShimmer( - text = "Albums", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - - TextButton( - onClick = onViewAll, - enabled = hasMore && !isLoading, - ) { - TextWithShimmer("View all") - } - } - - if (isLoading && albums.isEmpty()) { - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items(4) { - SkeletonTree(true) { - PlayableCard( - title = "Album Title", - subtitle = "Artist Name", - imageURL = "https://placehold.co/600x400", - ) - } - } - } - } else if (error != null && albums.isEmpty()) { - TextWithShimmer( - text = error, - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.error, - ) - } else if (albums.isEmpty()) { - TextWithShimmer( - text = "No albums found", - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - val rowState = rememberLazyListState() - LazyRow( - state = rowState, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items(albums, key = { it.id }) { album -> - AlbumCard(album = album) - } + Button(onClick = onFollowClick) { + Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) } } } @@ -487,7 +539,7 @@ private fun TopTracksHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - TextWithShimmer( + Text( text = "Top Tracks", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, @@ -510,6 +562,130 @@ private fun TopTracksHeader( } } +@Composable +private fun AlbumsSection( + albums: List, + onAlbumClick: (MetadataAlbum.Detailed) -> Unit, + onLoadMore: () -> Unit, +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = "Albums") + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + items(albums, key = { it.id }) { album -> + AlbumCard(album = album) + } + } +} + +@Composable +private fun RelatedArtistsSection( + artists: List, + onArtistClick: (MetadataArtist.Basic) -> Unit, + onLoadMore: () -> Unit, +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = "Related Artists") + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + items(artists, key = { it.id }) { artist -> + ArtistCard(artist = artist) + } + } +} + +@Composable +private fun FeaturedPlaylistsSection( + playlists: List, + onPlaylistClick: (MetadataPlaylist) -> Unit, + onLoadMore: () -> Unit, +) { + val rowState = rememberLazyListState() + + LaunchedEffect(rowState) { + snapshotFlow { rowState.layoutInfo } + .map { layoutInfo -> + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index + val totalItems = layoutInfo.totalItemsCount + Pair(lastVisibleIndex, totalItems) + } + .distinctUntilChanged() + .collect { (lastVisibleIndex, totalItems) -> + if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) { + onLoadMore() + } + } + } + + SectionHeader(title = "Featured Playlists") + Spacer(modifier = Modifier.size(8.dp)) + LazyRow( + state = rowState, + modifier = Modifier.dragScrollable(rowState), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + items(playlists, key = { it.id }) { playlist -> + PlaylistCard(playlist = playlist) + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + private fun formatFollowers(count: Int?): String { if (count == null) return "Followers unavailable" return when { @@ -529,4 +705,3 @@ private fun formatAbbreviatedCount(count: Int, divisor: Int): String { rounded.toString() } } - diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index b670cd46..2fa42a74 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -22,106 +22,77 @@ import androidx.lifecycle.viewModelScope import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.modules.library.LibraryRepository -import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent -data class ArtistInfoState( - val artist: MetadataArtist.Detailed? = null, - val isSaved: Boolean = false, - val isLoading: Boolean = false, - val isSaving: Boolean = false, - val error: String? = null, -) +sealed interface ArtistScreenState { + data object Loading : ArtistScreenState -data class ArtistTopTracksState( - val items: List = emptyList(), - val isLoading: Boolean = false, - val error: String? = null, -) + data class Loaded( + val artist: MetadataArtist.Detailed, + val isArtistSaved: Boolean, + val topTracks: List, + val albums: List, + val albumsNextPagination: PaginationStrategy?, + val relatedArtists: List, + val relatedArtistsNextPagination: PaginationStrategy?, + val featuredPlaylists: List, + val featuredPlaylistsNextPagination: PaginationStrategy?, + ) : ArtistScreenState -data class ArtistAlbumsState( - val items: List = emptyList(), - val nextPagination: PaginationStrategy? = null, - val hasNextPage: Boolean = true, - val isLoading: Boolean = false, - val error: String? = null, -) + data class Error(val message: String) : ArtistScreenState +} -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalStdlibApi::class) class ArtistViewModel( private val artistId: String, - private val pluginManager: PluginManager, - private val savedTracksRepository: SavedTracksRepository, + private val repository: ArtistRepository, private val libraryRepository: LibraryRepository, + private val savedTracksRepository: SavedTracksRepository, private val audioPlayerQueue: AudioPlayerQueue, -) : ViewModel() { - companion object { - private const val ALBUMS_PAGE_SIZE = 20 - } +) : ViewModel(), KoinComponent { + private val logger by injectLogger() - private val _artistInfo = MutableStateFlow(ArtistInfoState()) - val artistInfo: StateFlow = _artistInfo.asStateFlow() + private val _state = MutableStateFlow(ArtistScreenState.Loading) + val state: StateFlow = _state.asStateFlow() - private val _topTracks = MutableStateFlow(ArtistTopTracksState()) - val topTracks: StateFlow = _topTracks.asStateFlow() - - private val _albums = MutableStateFlow(ArtistAlbumsState()) - val albums: StateFlow = _albums.asStateFlow() val savedArtistIds get() = libraryRepository.savedArtistIdsFlow + val savedTrackIds + get() = savedTracksRepository.savedTracksIdsFlow + init { viewModelScope.launch { - pluginManager.selectedMetadataPlugin - .filterNotNull() - .distinctUntilChanged() - .flatMapLatest { it.loggedInFlow } - .collect { - _artistInfo.value = ArtistInfoState() - _topTracks.value = ArtistTopTracksState() - _albums.value = ArtistAlbumsState() - - loadArtistInfo() - loadTopTracks() - loadAlbumsPage(reset = true) + libraryRepository.savedArtistIdsFlow.collect { + val currentState = _state.value + if (currentState is ArtistScreenState.Loaded) { + _state.value = currentState.copy(isArtistSaved = it.contains(artistId)) } + } } + loadOverview() } - fun refreshArtist() { - viewModelScope.launch { - loadArtistInfo() - loadTopTracks() - loadAlbumsPage(reset = true) - } - } - - fun loadNextAlbumsPage() { - val current = _albums.value - if (current.isLoading || !current.hasNextPage || current.nextPagination == null) return - - viewModelScope.launch { - loadAlbumsPage(reset = false) - } + fun refresh() { + repository.invalidateCaches() + loadOverview() } fun toggleSavedArtist() { viewModelScope.launch { - val isLiked = - libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false + val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false if (isLiked) { libraryRepository.removeSavedArtists(listOf(artistId)) } else { @@ -130,11 +101,73 @@ class ArtistViewModel( } } + fun loadMoreAlbums() { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return + val pagination = currentState.albumsNextPagination ?: return + + viewModelScope.launch { + runCatching { + repository.albums(artistId, pagination) + }.onSuccess { result -> + if (result != null) { + _state.value = currentState.copy( + albums = currentState.albums + result.items, + albumsNextPagination = result.nextPagination, + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more albums" } + } + } + } + + fun loadMoreRelatedArtists() { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return + val pagination = currentState.relatedArtistsNextPagination ?: return + + viewModelScope.launch { + runCatching { + repository.relatedArtists(artistId, pagination) + }.onSuccess { result -> + if (result != null) { + _state.value = currentState.copy( + relatedArtists = currentState.relatedArtists + result.items, + relatedArtistsNextPagination = result.nextPagination, + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more related artists" } + } + } + } + + fun loadMoreFeaturedPlaylists() { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return + val pagination = currentState.featuredPlaylistsNextPagination ?: return + + viewModelScope.launch { + runCatching { + repository.featuredPlaylists(artistId, pagination) + }.onSuccess { result -> + if (result != null) { + _state.value = currentState.copy( + featuredPlaylists = currentState.featuredPlaylists + result.items, + featuredPlaylistsNextPagination = result.nextPagination, + ) + } + }.onFailure { e -> + logger.e(e) { "Failed to load more featured playlists" } + } + } + } + fun addTopTracksToQueue() { viewModelScope.launch { val entries = resolveTopTrackEntries() if (entries.isEmpty()) return@launch - audioPlayerQueue.addAllToQueue(entries) } } @@ -143,7 +176,6 @@ class ArtistViewModel( viewModelScope.launch { val entries = resolveTopTrackEntries() if (entries.isEmpty()) return@launch - audioPlayerQueue.load( entries = entries, autoPlay = true, @@ -180,128 +212,17 @@ class ArtistViewModel( } } - private suspend fun loadArtistInfo() { - val plugin = pluginManager.selectedMetadataPlugin.value - - if (plugin == null) { - _artistInfo.value = ArtistInfoState( - artist = null, - isSaved = false, - isLoading = false, - isSaving = false, - error = null, - ) - return - } - - _artistInfo.value = _artistInfo.value.copy(isLoading = true, error = null) - - runCatching { - pluginManager.asyncTask { - plugin.use { - val artist = metadataArtistAPI.getArtist(artistId) - val isSaved = - metadataArtistAPI.isSavedArtists(listOf(artistId)).firstOrNull() ?: false - artist to isSaved - } - }.await() - }.onSuccess { (artist, isSaved) -> - _artistInfo.value = _artistInfo.value.copy( - artist = artist, - isSaved = isSaved, - isLoading = false, - error = null, - ) - libraryRepository.isSavedArtists(listOf(artistId)) - }.onFailure { throwable -> - _artistInfo.value = _artistInfo.value.copy( - isLoading = false, - error = throwable.message ?: "Failed to load artist", - ) + fun addTracksToQueue(tracks: List) { + viewModelScope.launch { + val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + audioPlayerQueue.addAllToQueue(entries) } } - private suspend fun loadTopTracks() { - val plugin = pluginManager.selectedMetadataPlugin.value - - if (plugin == null) { - _topTracks.value = - ArtistTopTracksState(items = emptyList(), isLoading = false, error = null) - return - } - - _topTracks.value = _topTracks.value.copy(isLoading = true, error = null) - - runCatching { - pluginManager.asyncTask { - plugin.use { - metadataArtistAPI.getArtistTop10Tracks(artistId) - } - }.await() - }.onSuccess { tracks -> - savedTracksRepository.isSavedTracks(tracks.map { item -> item.id }) - _topTracks.value = ArtistTopTracksState( - items = tracks, - isLoading = false, - error = null, - ) - }.onFailure { throwable -> - _topTracks.value = _topTracks.value.copy( - isLoading = false, - error = throwable.message ?: "Failed to load artist top tracks", - ) - } - } - - private suspend fun loadAlbumsPage(reset: Boolean) { - val plugin = pluginManager.selectedMetadataPlugin.value - - if (plugin == null) { - _albums.value = ArtistAlbumsState( - items = emptyList(), - nextPagination = null, - hasNextPage = false, - isLoading = false, - error = null, - ) - return - } - - val current = _albums.value - val offset = if (reset) 0 else current.nextPagination ?: return - - _albums.value = if (reset) { - current.copy(items = emptyList(), isLoading = true, error = null) - } else { - current.copy(isLoading = true, error = null) - } - - runCatching { - pluginManager.asyncTask { - plugin.use { - metadataArtistAPI.getArtistAlbums(artistId) - } - }.await() - }.onSuccess { page -> - val mergedItems = if (reset) page.items else _albums.value.items + page.items - _albums.value = ArtistAlbumsState( - items = mergedItems, - nextPagination = page.nextPagination, - hasNextPage = page.nextPagination != null, - isLoading = false, - error = null, - ) - }.onFailure { throwable -> - _albums.value = _albums.value.copy( - isLoading = false, - error = throwable.message ?: "Failed to load artist albums", - ) - } - } - - private fun resolveTopTrackEntries(): List { - return _topTracks.value.items.map { track -> - QueueEntry.StreamingTrack(track = track, url = "") + fun playTracksNext(tracks: List) { + viewModelScope.launch { + val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } + audioPlayerQueue.addAllAfterCurrent(entries) } } @@ -352,28 +273,47 @@ class ArtistViewModel( } } - fun addTracksToQueue(tracks: List) { + private fun loadOverview() { + _state.value = ArtistScreenState.Loading viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) + runCatching { + repository.artistOverview(artistId) + }.onSuccess { overview -> + if (overview != null) { + savedTracksRepository.isSavedTracks(overview.top10Tracks.map { it.id }) + _state.value = ArtistScreenState.Loaded( + artist = overview.artist, + isArtistSaved = libraryRepository.savedArtistIdsFlow.value.contains(artistId), + topTracks = overview.top10Tracks, + albums = overview.albums.items, + albumsNextPagination = overview.albums.nextPagination, + relatedArtists = overview.relatedArtists.items, + relatedArtistsNextPagination = overview.relatedArtists.nextPagination, + featuredPlaylists = overview.featuredPlaylists.items, + featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination, + ) + } else { + _state.value = ArtistScreenState.Error("Failed to load artist overview") + } + }.onFailure { e -> + logger.e(e) { "Failed to load artist overview" } + _state.value = ArtistScreenState.Error(e.message ?: "Unknown error") + } } } - fun playTracksNext(tracks: List) { - viewModelScope.launch { - val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) + private fun resolveTopTrackEntries(): List { + val currentState = _state.value + if (currentState !is ArtistScreenState.Loaded) return emptyList() + return currentState.topTracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") } } - val savedTrackIds - get() = savedTracksRepository.savedTracksIdsFlow - private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { if (id.isNotBlank() && other.id.isNotBlank()) { return id == other.id } - return title == other.title && durationMs == other.durationMs && album?.id == other.album?.id && diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt index 1cc61edf..80af3ed4 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt @@ -19,8 +19,10 @@ package dev.krtirtho.js_plugin_example.plugin_apis.metadata import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack class RealMetadataArtistAPI : MetadataArtistAPI { @@ -29,10 +31,28 @@ class RealMetadataArtistAPI : MetadataArtistAPI { return FakeMetadataStore.getArtist(id) } + override suspend fun artistOverview(id: String): MetadataArtistOverview { + TODO("Not yet implemented") + } + override suspend fun getArtistTop10Tracks(id: String): List { return FakeMetadataStore.getArtistTopTracks(id) } + override suspend fun relatedArtists( + id: String, + pagination: PaginationStrategy? + ): PaginationResult { + TODO("Not yet implemented") + } + + override suspend fun featuredPlaylists( + id: String, + pagination: PaginationStrategy? + ): PaginationResult { + TODO("Not yet implemented") + } + override suspend fun getArtistAlbums( id: String, pagination: PaginationStrategy? diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt index c0dc9816..5d4aaa5a 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt @@ -20,13 +20,26 @@ import app.cash.zipline.ZiplineService import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI" interface MetadataArtistAPI : ZiplineService { suspend fun getArtist(id: String): MetadataArtist.Detailed + suspend fun artistOverview(id: String): MetadataArtistOverview suspend fun getArtistTop10Tracks(id: String): List + + suspend fun relatedArtists( + id: String, + pagination: PaginationStrategy? = null + ): PaginationResult + + suspend fun featuredPlaylists( + id: String, + pagination: PaginationStrategy? = null + ): PaginationResult + suspend fun getArtistAlbums( id: String, pagination: PaginationStrategy? = null diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt index f8ec18b0..750eb0b5 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt @@ -16,7 +16,11 @@ package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -47,4 +51,13 @@ sealed class MetadataArtist { override val thumbnails: List, override val externalUri: String? ) : MetadataArtist() -} \ No newline at end of file +} + +@Serializable +data class MetadataArtistOverview( + val artist: MetadataArtist.Detailed, + val top10Tracks: List, + val albums: PaginationResult, + val relatedArtists: PaginationResult, + val featuredPlaylists: PaginationResult +) \ No newline at end of file