mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat: implement artist overview screen with loading, error handling, and pagination for albums, related artists, and featured playlists
This commit is contained in:
parent
8f9c2540a2
commit
f05f0727b6
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String, MetadataArtistOverview>().build()
|
||||
private val albumsCache =
|
||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataAlbum.Detailed>>().build()
|
||||
private val relatedArtistsCache =
|
||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataArtist.Basic>>().build()
|
||||
private val featuredPlaylistsCache =
|
||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataPlaylist>>().build()
|
||||
private val topTracksCache = Cache.Builder<String, List<MetadataTrack>>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<NavigationCommands>()
|
||||
|
||||
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<MetadataAlbum.Detailed>,
|
||||
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<MetadataAlbum.Detailed>,
|
||||
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<MetadataArtist.Basic>,
|
||||
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<MetadataPlaylist>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<MetadataTrack> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
data class Loaded(
|
||||
val artist: MetadataArtist.Detailed,
|
||||
val isArtistSaved: Boolean,
|
||||
val topTracks: List<MetadataTrack>,
|
||||
val albums: List<MetadataAlbum.Detailed>,
|
||||
val albumsNextPagination: PaginationStrategy?,
|
||||
val relatedArtists: List<MetadataArtist.Basic>,
|
||||
val relatedArtistsNextPagination: PaginationStrategy?,
|
||||
val featuredPlaylists: List<MetadataPlaylist>,
|
||||
val featuredPlaylistsNextPagination: PaginationStrategy?,
|
||||
) : ArtistScreenState
|
||||
|
||||
data class ArtistAlbumsState(
|
||||
val items: List<MetadataAlbum.Detailed> = 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<ArtistViewModel>()
|
||||
|
||||
private val _artistInfo = MutableStateFlow(ArtistInfoState())
|
||||
val artistInfo: StateFlow<ArtistInfoState> = _artistInfo.asStateFlow()
|
||||
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
|
||||
val state: StateFlow<ArtistScreenState> = _state.asStateFlow()
|
||||
|
||||
private val _topTracks = MutableStateFlow(ArtistTopTracksState())
|
||||
val topTracks: StateFlow<ArtistTopTracksState> = _topTracks.asStateFlow()
|
||||
|
||||
private val _albums = MutableStateFlow(ArtistAlbumsState())
|
||||
val albums: StateFlow<ArtistAlbumsState> = _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<MetadataTrack>) {
|
||||
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<QueueEntry> {
|
||||
return _topTracks.value.items.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
@ -352,28 +273,47 @@ class ArtistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
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<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
private fun resolveTopTrackEntries(): List<QueueEntry> {
|
||||
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 &&
|
||||
|
||||
@ -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<MetadataTrack> {
|
||||
return FakeMetadataStore.getArtistTopTracks(id)
|
||||
}
|
||||
|
||||
override suspend fun relatedArtists(
|
||||
id: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataArtist.Basic> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun featuredPlaylists(
|
||||
id: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataPlaylist> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getArtistAlbums(
|
||||
id: String,
|
||||
pagination: PaginationStrategy?
|
||||
|
||||
@ -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<MetadataTrack>
|
||||
|
||||
suspend fun relatedArtists(
|
||||
id: String,
|
||||
pagination: PaginationStrategy? = null
|
||||
): PaginationResult<MetadataArtist.Basic>
|
||||
|
||||
suspend fun featuredPlaylists(
|
||||
id: String,
|
||||
pagination: PaginationStrategy? = null
|
||||
): PaginationResult<MetadataPlaylist>
|
||||
|
||||
suspend fun getArtistAlbums(
|
||||
id: String,
|
||||
pagination: PaginationStrategy? = null
|
||||
|
||||
@ -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
|
||||
|
||||
@ -48,3 +52,12 @@ sealed class MetadataArtist {
|
||||
override val externalUri: String?
|
||||
) : MetadataArtist()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MetadataArtistOverview(
|
||||
val artist: MetadataArtist.Detailed,
|
||||
val top10Tracks: List<MetadataTrack>,
|
||||
val albums: PaginationResult<MetadataAlbum.Detailed>,
|
||||
val relatedArtists: PaginationResult<MetadataArtist.Basic>,
|
||||
val featuredPlaylists: PaginationResult<MetadataPlaylist>
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user