mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
No commits in common. "f012defd7991d9d9760084ab87f426f88b530560" and "7d4d11705aef9d118e7a59e6d796b157e469c64e" have entirely different histories.
f012defd79
...
7d4d11705a
@ -29,7 +29,6 @@ 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
|
||||
@ -128,13 +127,12 @@ val sharedModules = module {
|
||||
viewModelOf(::SavedTracksViewModel)
|
||||
|
||||
// Artist
|
||||
singleOf(::ArtistRepository)
|
||||
viewModel { (artistId: String) ->
|
||||
ArtistViewModel(
|
||||
artistId = artistId,
|
||||
repository = get(),
|
||||
libraryRepository = get(),
|
||||
pluginManager = get(),
|
||||
savedTracksRepository = get(),
|
||||
libraryRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -146,7 +146,6 @@ 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) }
|
||||
@ -208,128 +207,24 @@ fun TrackList(
|
||||
val showAlbum = !isCompact
|
||||
val useDropdownForOptions = isDesktop && !isCompact
|
||||
|
||||
val trackCardContent: @Composable () -> Unit = {
|
||||
Card(
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
.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),
|
||||
) {
|
||||
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 (headerContent != null) {
|
||||
item {
|
||||
headerContent()
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
if (!simplified)
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||
.heightIn(max = 60.dp),
|
||||
@ -450,26 +345,127 @@ fun TrackList(
|
||||
}
|
||||
}
|
||||
|
||||
if (scrollable) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
item(key = "track-card") {
|
||||
Card(
|
||||
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),
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
if (headerContent != null) {
|
||||
item {
|
||||
headerContent()
|
||||
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 (!simplified) {
|
||||
item { filterSortRow() }
|
||||
|
||||
if (error != null) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
item(key = "track-card") { trackCardContent() }
|
||||
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 (footerContent != null) {
|
||||
item {
|
||||
footerContent()
|
||||
@ -493,40 +489,7 @@ fun TrackList(
|
||||
}
|
||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
if (headerContent != null) {
|
||||
headerContent()
|
||||
}
|
||||
if (!simplified) {
|
||||
filterSortRow()
|
||||
}
|
||||
trackCardContent()
|
||||
if (footerContent != null) {
|
||||
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) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
||||
@ -125,7 +125,6 @@ open class ZiplinePluginService(
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
@ -272,7 +271,6 @@ open class ZiplinePluginService(
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
@ -282,8 +280,6 @@ open class ZiplinePluginService(
|
||||
)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
@ -314,19 +310,10 @@ open class ZiplinePluginService(
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
loggedInStateFlow.value = false
|
||||
ziplineDispatcher.close()
|
||||
|
||||
@ -1,140 +0,0 @@
|
||||
/*
|
||||
* 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,13 +24,12 @@ 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
|
||||
@ -46,11 +45,9 @@ 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
|
||||
@ -62,12 +59,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
|
||||
@ -78,19 +75,15 @@ 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 kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@ -108,7 +101,9 @@ fun ArtistScreen(artistId: String) {
|
||||
)
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val artistInfo by viewModel.artistInfo.collectAsStateWithLifecycle()
|
||||
val topTracksState by viewModel.topTracks.collectAsStateWithLifecycle()
|
||||
val albumsState by viewModel.albums.collectAsStateWithLifecycle()
|
||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
@ -128,6 +123,8 @@ fun ArtistScreen(artistId: String) {
|
||||
)
|
||||
}
|
||||
|
||||
val artist = artistInfo.artist
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
viewModel.handleTrackOptionsAction(track, action)
|
||||
if (action is TrackOptionsAction.Share) {
|
||||
@ -144,227 +141,76 @@ fun ArtistScreen(artistId: String) {
|
||||
Scaffold(
|
||||
topBar = { ApplicationMainBar() }
|
||||
) { innerPadding ->
|
||||
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),
|
||||
TrackList(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
headerContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
item {
|
||||
ArtistHeaderCard(
|
||||
artist = currentState.artist,
|
||||
isSaved = savedArtistIds.contains(currentState.artist.id),
|
||||
artist = artist,
|
||||
isSaved = savedArtistIds.contains(artistId),
|
||||
isLoading = artistInfo.isLoading,
|
||||
isSaving = artistInfo.isSaving,
|
||||
error = artistInfo.error,
|
||||
onFollowClick = viewModel::toggleSavedArtist,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ArtistAlbumsSection(
|
||||
albums = albumsState.items,
|
||||
isLoading = albumsState.isLoading,
|
||||
error = albumsState.error,
|
||||
hasMore = albumsState.hasNextPage,
|
||||
onViewAll = viewModel::loadNextAlbumsPage,
|
||||
)
|
||||
|
||||
TopTracksHeader(
|
||||
onPlay = viewModel::playTopTracks,
|
||||
onAddToQueue = viewModel::addTopTracksToQueue,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TrackList(
|
||||
tracks = currentState.topTracks,
|
||||
error = null,
|
||||
},
|
||||
tracks = topTracksState.items,
|
||||
error = topTracksState.error,
|
||||
hasMore = false,
|
||||
isLoading = 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 = {},
|
||||
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
|
||||
)
|
||||
)
|
||||
onBulkDownload = { tracks ->
|
||||
downloadsViewModel.downloadTracks(tracks)
|
||||
},
|
||||
onLoadMore = { viewModel.loadMoreAlbums() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) {
|
||||
item {
|
||||
RelatedArtistsSection(
|
||||
artists = currentState.relatedArtists,
|
||||
onArtistClick = { artist ->
|
||||
navigationCommands.navigateTo(
|
||||
Routes.Artist(
|
||||
artist.id
|
||||
)
|
||||
)
|
||||
onBulkAddToQueue = { tracks ->
|
||||
viewModel.addTracksToQueue(tracks)
|
||||
},
|
||||
onLoadMore = { viewModel.loadMoreRelatedArtists() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) {
|
||||
item {
|
||||
FeaturedPlaylistsSection(
|
||||
playlists = currentState.featuredPlaylists,
|
||||
onPlaylistClick = { playlist ->
|
||||
navigationCommands.navigateTo(
|
||||
Routes.Playlist(
|
||||
playlist.id
|
||||
)
|
||||
)
|
||||
onBulkPlayNext = { tracks ->
|
||||
viewModel.playTracksNext(tracks)
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
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()
|
||||
@ -394,8 +240,17 @@ private fun ArtistHeaderCard(
|
||||
|
||||
ArtistHeaderActions(
|
||||
isSaved = isSaved,
|
||||
isSaving = isSaving,
|
||||
onFollowClick = onFollowClick,
|
||||
)
|
||||
|
||||
error?.let {
|
||||
TextWithShimmer(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
@ -421,8 +276,18 @@ private fun ArtistHeaderCard(
|
||||
|
||||
ArtistHeaderActions(
|
||||
isSaved = isSaved,
|
||||
isSaving = isSaving,
|
||||
onFollowClick = onFollowClick,
|
||||
)
|
||||
|
||||
error?.let {
|
||||
TextWithShimmer(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -432,28 +297,29 @@ 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),
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.shimmerApply(),
|
||||
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,
|
||||
contentDescription = artist?.name ?: "Artist",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.User,
|
||||
contentDescription = artist.name,
|
||||
contentDescription = artist?.name ?: "Artist",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(size * 0.4f),
|
||||
)
|
||||
@ -463,7 +329,7 @@ private fun ArtistAvatar(
|
||||
|
||||
@Composable
|
||||
private fun ArtistMeta(
|
||||
artist: MetadataArtist.Detailed,
|
||||
artist: MetadataArtist.Detailed?,
|
||||
isCompact: Boolean,
|
||||
) {
|
||||
Column(
|
||||
@ -471,36 +337,38 @@ private fun ArtistMeta(
|
||||
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = artist.name,
|
||||
TextWithShimmer(
|
||||
text = artist?.name ?: "Loading artist...",
|
||||
style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Text(
|
||||
artist?.let {
|
||||
TextWithShimmer(
|
||||
text = buildString {
|
||||
append(formatFollowers(artist.followersCount))
|
||||
if (artist.genres.isNotEmpty()) {
|
||||
append(formatFollowers(it.followersCount))
|
||||
if (it.genres.isNotEmpty()) {
|
||||
append(" • ")
|
||||
append(artist.genres.joinToString(", "))
|
||||
append(it.genres.joinToString(", "))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
artist.biography?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
artist?.biography?.takeIf { it.isNotBlank() }?.let {
|
||||
TextWithShimmer(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = if (isCompact) 4 else 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -509,6 +377,7 @@ private fun ArtistMeta(
|
||||
@Composable
|
||||
private fun ArtistHeaderActions(
|
||||
isSaved: Boolean,
|
||||
isSaving: Boolean,
|
||||
onFollowClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@ -516,12 +385,91 @@ private fun ArtistHeaderActions(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isSaved) {
|
||||
FilledTonalButton(onClick = onFollowClick) {
|
||||
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||
FilledTonalButton(onClick = onFollowClick, enabled = !isSaving) {
|
||||
TextWithShimmer("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onFollowClick) {
|
||||
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -539,7 +487,7 @@ private fun TopTracksHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
TextWithShimmer(
|
||||
text = "Top Tracks",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@ -562,130 +510,6 @@ 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 {
|
||||
@ -705,3 +529,4 @@ private fun formatAbbreviatedCount(count: Int, divisor: Int): String {
|
||||
rounded.toString()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,77 +22,106 @@ 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
|
||||
|
||||
sealed interface ArtistScreenState {
|
||||
data object Loading : ArtistScreenState
|
||||
data class ArtistInfoState(
|
||||
val artist: MetadataArtist.Detailed? = null,
|
||||
val isSaved: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isSaving: 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 ArtistTopTracksState(
|
||||
val items: List<MetadataTrack> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
data class Error(val message: String) : 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,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ArtistViewModel(
|
||||
private val artistId: String,
|
||||
private val repository: ArtistRepository,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val pluginManager: PluginManager,
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<ArtistViewModel>()
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private const val ALBUMS_PAGE_SIZE = 20
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
|
||||
val state: StateFlow<ArtistScreenState> = _state.asStateFlow()
|
||||
private val _artistInfo = MutableStateFlow(ArtistInfoState())
|
||||
val artistInfo: StateFlow<ArtistInfoState> = _artistInfo.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 {
|
||||
libraryRepository.savedArtistIdsFlow.collect {
|
||||
val currentState = _state.value
|
||||
if (currentState is ArtistScreenState.Loaded) {
|
||||
_state.value = currentState.copy(isArtistSaved = it.contains(artistId))
|
||||
pluginManager.selectedMetadataPlugin
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { it.loggedInFlow }
|
||||
.collect {
|
||||
_artistInfo.value = ArtistInfoState()
|
||||
_topTracks.value = ArtistTopTracksState()
|
||||
_albums.value = ArtistAlbumsState()
|
||||
|
||||
loadArtistInfo()
|
||||
loadTopTracks()
|
||||
loadAlbumsPage(reset = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadOverview()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
repository.invalidateCaches()
|
||||
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 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 {
|
||||
@ -101,73 +130,11 @@ 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)
|
||||
}
|
||||
}
|
||||
@ -176,6 +143,7 @@ class ArtistViewModel(
|
||||
viewModelScope.launch {
|
||||
val entries = resolveTopTrackEntries()
|
||||
if (entries.isEmpty()) return@launch
|
||||
|
||||
audioPlayerQueue.load(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
@ -212,17 +180,128 @@ class ArtistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
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 playTracksNext(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(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 = "")
|
||||
}
|
||||
}
|
||||
|
||||
@ -273,47 +352,28 @@ class ArtistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadOverview() {
|
||||
_state.value = ArtistScreenState.Loading
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
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")
|
||||
}
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(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 = "")
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
viewModelScope.launch {
|
||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
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 &&
|
||||
|
||||
@ -49,11 +49,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||
@ -83,15 +80,6 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val selectedGenreId = when (val s = state) {
|
||||
is HomeScreenState.Data -> s.selectedGenreId
|
||||
else -> null
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedGenreId) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.map { layoutInfo ->
|
||||
@ -131,7 +119,6 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
listState = listState,
|
||||
state = state,
|
||||
onRetry = { viewModel.refresh() },
|
||||
onGenreSelected = { viewModel.selectGenre(it) },
|
||||
)
|
||||
} else {
|
||||
PullToRefreshBox(
|
||||
@ -145,7 +132,6 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
listState = listState,
|
||||
state = state,
|
||||
onRetry = { viewModel.refresh() },
|
||||
onGenreSelected = { viewModel.selectGenre(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -164,7 +150,6 @@ private fun HomeContent(
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
state: HomeScreenState,
|
||||
onRetry: () -> Unit,
|
||||
onGenreSelected: (String) -> Unit,
|
||||
) {
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
@ -231,55 +216,13 @@ private fun HomeContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.genres.isNotEmpty()) {
|
||||
item {
|
||||
GenreTabs(
|
||||
genres = state.genres,
|
||||
selectedGenreId = state.selectedGenreId,
|
||||
onGenreSelected = onGenreSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val currentSections = state.selectedGenreId?.let { state.browseSections[it] }
|
||||
|
||||
if (currentSections == null) {
|
||||
item {
|
||||
SkeletonTree(true) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp)
|
||||
.padding(horizontal = 16.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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(currentSections) { section ->
|
||||
items(state.browseSections) { section ->
|
||||
HomeSection(
|
||||
title = section.title,
|
||||
subtitle = section.description,
|
||||
items = section.items,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state is HomeScreenState.Data.LoadingMore) {
|
||||
item {
|
||||
@ -364,26 +307,3 @@ private fun HomeSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreTabs(
|
||||
genres: List<MetadataBrowseGenre>,
|
||||
selectedGenreId: String?,
|
||||
onGenreSelected: (String) -> Unit,
|
||||
) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
modifier = Modifier.dragScrollable(rowState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(genres) { genre ->
|
||||
ChipTab(
|
||||
text = genre.name,
|
||||
selected = selectedGenreId == genre.id,
|
||||
onClick = { onGenreSelected(genre.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.home
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
@ -46,10 +45,8 @@ class HomeScreenRepository(
|
||||
get() = pluginManager.selectedMetadataPlugin.value
|
||||
|
||||
private val featuredItemCache = Cache.Builder<String, List<MetadataBrowseItem>>().build()
|
||||
private val genresCache = Cache.Builder<String, List<MetadataBrowseGenre>>().build()
|
||||
private val browseItemCache =
|
||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseSection>>()
|
||||
.build()
|
||||
Cache.Builder<PaginationStrategy, PaginationResult<MetadataBrowseSection>>().build()
|
||||
private val sublistItemCache =
|
||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>()
|
||||
.build()
|
||||
@ -68,7 +65,6 @@ class HomeScreenRepository(
|
||||
|
||||
fun invalidateCaches() {
|
||||
featuredItemCache.invalidateAll()
|
||||
genresCache.invalidateAll()
|
||||
browseItemCache.invalidateAll()
|
||||
sublistItemCache.invalidateAll()
|
||||
}
|
||||
@ -83,43 +79,32 @@ class HomeScreenRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun genres() = plugin?.let { plugin ->
|
||||
genresCache.get("genres") {
|
||||
pluginManager.withScope {
|
||||
plugin.use {
|
||||
metadataBrowseAPI.genres()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun list(genreId: String, paginationStrategy: PaginationStrategy? = null) =
|
||||
suspend fun list(paginationStrategy: PaginationStrategy? = null) =
|
||||
plugin?.let { plugin ->
|
||||
browseItemCache.get(
|
||||
key = genreId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
|
||||
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
|
||||
) {
|
||||
pluginManager.withScope {
|
||||
plugin.use {
|
||||
metadataBrowseAPI.list(genreId, paginationStrategy)
|
||||
metadataBrowseAPI.list(paginationStrategy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sublist(
|
||||
genreId: String,
|
||||
parentId: String,
|
||||
paginationStrategy: PaginationStrategy? = null
|
||||
) = plugin?.let { plugin ->
|
||||
sublistItemCache.get(
|
||||
"$genreId/$parentId" to (paginationStrategy ?: PaginationStrategy.Offset(
|
||||
parentId to (paginationStrategy ?: PaginationStrategy.Offset(
|
||||
0,
|
||||
20
|
||||
))
|
||||
) {
|
||||
pluginManager.withScope {
|
||||
plugin.use {
|
||||
metadataBrowseAPI.sublist(genreId, parentId, paginationStrategy)
|
||||
metadataBrowseAPI.sublist(parentId, paginationStrategy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ package dev.krtirtho.spotube.modules.home
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||
@ -28,11 +27,19 @@ import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.scan
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@ -41,33 +48,29 @@ sealed interface HomeScreenState {
|
||||
|
||||
sealed interface Data : HomeScreenState {
|
||||
val featuredItems: List<MetadataBrowseItem>
|
||||
val genres: List<MetadataBrowseGenre>
|
||||
val selectedGenreId: String?
|
||||
val browseSections: Map<String, List<MetadataBrowseSection>?>
|
||||
val paginationStrategies: Map<String, PaginationStrategy?>
|
||||
val browseSections: List<MetadataBrowseSection>
|
||||
val paginationStrategy: PaginationStrategy?
|
||||
val totalItems: Int
|
||||
|
||||
data class Loaded(
|
||||
override val featuredItems: List<MetadataBrowseItem>,
|
||||
override val genres: List<MetadataBrowseGenre>,
|
||||
override val selectedGenreId: String?,
|
||||
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
|
||||
override val paginationStrategies: Map<String, PaginationStrategy?>,
|
||||
override val browseSections: List<MetadataBrowseSection>,
|
||||
override val paginationStrategy: PaginationStrategy?,
|
||||
override val totalItems: Int,
|
||||
) : Data {
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
featuredItems = featuredItems,
|
||||
genres = genres,
|
||||
selectedGenreId = selectedGenreId,
|
||||
browseSections = browseSections,
|
||||
paginationStrategies = paginationStrategies,
|
||||
paginationStrategy = paginationStrategy,
|
||||
totalItems = totalItems,
|
||||
)
|
||||
}
|
||||
|
||||
data class LoadingMore(
|
||||
override val featuredItems: List<MetadataBrowseItem>,
|
||||
override val genres: List<MetadataBrowseGenre>,
|
||||
override val selectedGenreId: String?,
|
||||
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
|
||||
override val paginationStrategies: Map<String, PaginationStrategy?>,
|
||||
override val browseSections: List<MetadataBrowseSection>,
|
||||
override val paginationStrategy: PaginationStrategy?,
|
||||
override val totalItems: Int,
|
||||
) : Data
|
||||
}
|
||||
|
||||
@ -96,67 +99,30 @@ class HomeScreenViewModel(
|
||||
|
||||
private suspend fun loadInitialData() = runCatching {
|
||||
state.value = HomeScreenState.Loading
|
||||
val featuredItems = repository.featuredItems() ?: emptyList()
|
||||
val genres = repository.genres() ?: emptyList()
|
||||
val firstGenreId = genres.firstOrNull()?.id
|
||||
val browseSections = mutableMapOf<String, List<MetadataBrowseSection>?>()
|
||||
val paginationStrategies = mutableMapOf<String, PaginationStrategy?>()
|
||||
if (firstGenreId != null) {
|
||||
val result = repository.list(firstGenreId)
|
||||
browseSections[firstGenreId] = result?.items ?: emptyList()
|
||||
paginationStrategies[firstGenreId] = result?.nextPagination
|
||||
}
|
||||
val featuredItems = repository.featuredItems()
|
||||
val browseSections = repository.list()
|
||||
state.value = HomeScreenState.Data.Loaded(
|
||||
featuredItems = featuredItems,
|
||||
genres = genres,
|
||||
selectedGenreId = firstGenreId,
|
||||
browseSections = browseSections,
|
||||
paginationStrategies = paginationStrategies,
|
||||
featuredItems = featuredItems ?: emptyList(),
|
||||
browseSections = browseSections?.items ?: emptyList(),
|
||||
paginationStrategy = browseSections?.nextPagination,
|
||||
totalItems = browseSections?.items?.size ?: 0,
|
||||
)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load home screen data" }
|
||||
state.value = HomeScreenState.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
|
||||
fun selectGenre(genreId: String) {
|
||||
val currentState = state.value
|
||||
if (currentState is HomeScreenState.Data) {
|
||||
state.value = when (currentState) {
|
||||
is HomeScreenState.Data.Loaded -> currentState.copy(selectedGenreId = genreId)
|
||||
is HomeScreenState.Data.LoadingMore -> currentState.copy(selectedGenreId = genreId)
|
||||
}
|
||||
if (currentState.browseSections[genreId] == null) {
|
||||
viewModelScope.launch {
|
||||
val result = repository.list(genreId)
|
||||
val current = state.value
|
||||
if (current is HomeScreenState.Data) {
|
||||
state.value = HomeScreenState.Data.Loaded(
|
||||
featuredItems = current.featuredItems,
|
||||
genres = current.genres,
|
||||
selectedGenreId = genreId,
|
||||
browseSections = current.browseSections + (genreId to (result?.items ?: emptyList())),
|
||||
paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadMoreData() = runCatching {
|
||||
val currentState = state.value
|
||||
if (currentState is HomeScreenState.Data.Loaded) {
|
||||
val genreId = currentState.selectedGenreId ?: return@runCatching
|
||||
val pagination = currentState.paginationStrategies[genreId] ?: return@runCatching
|
||||
if (currentState is HomeScreenState.Data.Loaded && currentState.paginationStrategy != null) {
|
||||
state.value = currentState.toLoadingMore()
|
||||
val result = repository.list(genreId, pagination)
|
||||
val existingSections = currentState.browseSections[genreId] ?: emptyList()
|
||||
val browseSectionsResult = repository.list(currentState.paginationStrategy)
|
||||
state.value = HomeScreenState.Data.Loaded(
|
||||
featuredItems = currentState.featuredItems,
|
||||
genres = currentState.genres,
|
||||
selectedGenreId = genreId,
|
||||
browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))),
|
||||
paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination),
|
||||
browseSections = currentState.browseSections + (browseSectionsResult?.items
|
||||
?: emptyList()),
|
||||
paginationStrategy = browseSectionsResult?.nextPagination,
|
||||
totalItems = currentState.totalItems,
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
|
||||
@ -34,7 +34,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
@ -148,13 +147,9 @@ class PluginManager(
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val ziplineServices = state
|
||||
.filterIsInstance<PluginManagerStates.Data>()
|
||||
.map { it.selectedPlugins to it.generation }
|
||||
// Paired with generation so same-version replaces (structurally equal
|
||||
// PluginEntry) still trigger a restart. Without it the DataStore skips
|
||||
// the emission (identical JSON) and the flow stays alive with stale code.
|
||||
.map { it.selectedPlugins }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { (plugins, gen) ->
|
||||
logger.d { "ziplineServices: flow restarting with generation=$gen, selectedPlugins=${plugins.keys}" }
|
||||
.flatMapLatest { plugins ->
|
||||
flow {
|
||||
val ziplineServices: MutableMap<PluginAbility, PluginService?> =
|
||||
mutableMapOf()
|
||||
@ -171,7 +166,6 @@ class PluginManager(
|
||||
ziplineServicesByPluginID[plugin.id] = service
|
||||
service.start()
|
||||
} else {
|
||||
logger.d { "ziplineServices: creating new ZiplinePluginService for ${plugin.name} (${plugin.id})" }
|
||||
val service = ZiplinePluginService(
|
||||
applicationName = plugin.name,
|
||||
manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}",
|
||||
@ -186,17 +180,13 @@ class PluginManager(
|
||||
// Keep the flow alive until the next plugin is selected.
|
||||
awaitCancellation()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
logger.d { "ziplineServices: finally block stopping ${ziplineServicesByPluginID.size} services" }
|
||||
ziplineServicesByPluginID.forEach { (_, service) ->
|
||||
try {
|
||||
// Stop services that are no longer selected
|
||||
val selectedPluginIDs = plugins.values.map { it.id }.toSet()
|
||||
ziplineServicesByPluginID.forEach { (pluginID, service) ->
|
||||
if (!selectedPluginIDs.contains(pluginID)) {
|
||||
service.stop()
|
||||
} catch (_: Exception) {
|
||||
logger.e { "ziplineServices: error stopping service" }
|
||||
}
|
||||
}
|
||||
logger.d { "ziplineServices: all services stopped" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -505,12 +495,7 @@ class PluginManager(
|
||||
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
||||
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
|
||||
}
|
||||
val newState = PluginManagerStates.Data(
|
||||
plugins = updatedPlugins,
|
||||
selectedPlugins = updatedSelectedPlugins,
|
||||
generation = currentState.generation + 1
|
||||
)
|
||||
logger.d { "addPlugin: bumped generation to ${newState.generation} for plugin ${plugin.id}" }
|
||||
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins)
|
||||
updatePluginsState(newState)
|
||||
}
|
||||
}
|
||||
@ -525,11 +510,7 @@ class PluginManager(
|
||||
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
|
||||
val updatedSelectedPlugins =
|
||||
currentState.selectedPlugins.filterValues { it.id != plugin.id }
|
||||
val newState = PluginManagerStates.Data(
|
||||
plugins = updatedPlugins,
|
||||
selectedPlugins = updatedSelectedPlugins,
|
||||
generation = currentState.generation
|
||||
)
|
||||
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val pluginDir = pluginsDir / plugin.id.toPath()
|
||||
|
||||
@ -55,12 +55,7 @@ sealed class PluginManagerStates {
|
||||
@Serializable
|
||||
data class Data(
|
||||
val plugins: List<PluginEntry>,
|
||||
val selectedPlugins: Map<PluginAbility, PluginEntry> = emptyMap(),
|
||||
// Bumped on every addPlugin so the DataStore always detects a structural
|
||||
// change — without it, same-version replaces produce identical JSON and
|
||||
// the DataStore's internal distinctUntilChanged blocks the state emission,
|
||||
// leaving the ziplineServices flow stuck with the old code.
|
||||
val generation: Long = 0L
|
||||
val selectedPlugins: Map<PluginAbility, PluginEntry> = emptyMap()
|
||||
) : PluginManagerStates()
|
||||
|
||||
data object Loading : PluginManagerStates()
|
||||
|
||||
@ -19,10 +19,8 @@ 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 {
|
||||
@ -31,28 +29,10 @@ 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?
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||
@ -29,22 +28,11 @@ class RealMetadataBrowseAPI : MetadataBrowseAPI {
|
||||
return FakeMetadataStore.getFeaturedItems()
|
||||
}
|
||||
|
||||
override suspend fun genres(): List<MetadataBrowseGenre> {
|
||||
return listOf(
|
||||
MetadataBrowseGenre(id = "1", name = "Pop"),
|
||||
MetadataBrowseGenre(id = "2", name = "Rock"),
|
||||
MetadataBrowseGenre(id = "3", name = "Hip-Hop"),
|
||||
MetadataBrowseGenre(id = "4", name = "Jazz"),
|
||||
MetadataBrowseGenre(id = "5", name = "Classical")
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun list(genreId: String, pagination: PaginationStrategy?): PaginationResult<MetadataBrowseSection> {
|
||||
override suspend fun list(pagination: PaginationStrategy?): PaginationResult<MetadataBrowseSection> {
|
||||
return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSections(), pagination)
|
||||
}
|
||||
|
||||
override suspend fun sublist(
|
||||
genreId: String,
|
||||
sectionId: String,
|
||||
pagination: PaginationStrategy?
|
||||
): PaginationResult<MetadataBrowseItem> {
|
||||
|
||||
@ -20,26 +20,13 @@ 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,11 +16,7 @@
|
||||
|
||||
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
|
||||
|
||||
@ -52,12 +48,3 @@ 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>
|
||||
)
|
||||
@ -24,7 +24,6 @@ const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI"
|
||||
|
||||
interface MetadataBrowseAPI: ZiplineService {
|
||||
suspend fun featured(): List<MetadataBrowseItem>
|
||||
suspend fun genres(): List<MetadataBrowseGenre>
|
||||
suspend fun list(genreId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
|
||||
suspend fun sublist(genreId: String, sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
|
||||
suspend fun list(pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
|
||||
suspend fun sublist(sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
|
||||
}
|
||||
@ -54,9 +54,3 @@ data class MetadataBrowseSection(
|
||||
val items: List<MetadataBrowseItem>,
|
||||
val moreLink: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MetadataBrowseGenre(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user