mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
3 Commits
7d4d11705a
...
f012defd79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f012defd79 | ||
|
|
f05f0727b6 | ||
|
|
8f9c2540a2 |
@ -29,6 +29,7 @@ import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
|||||||
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
||||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
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.artist.ArtistViewModel
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumViewModel
|
import dev.krtirtho.spotube.modules.album.AlbumViewModel
|
||||||
@ -127,12 +128,13 @@ val sharedModules = module {
|
|||||||
viewModelOf(::SavedTracksViewModel)
|
viewModelOf(::SavedTracksViewModel)
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
|
singleOf(::ArtistRepository)
|
||||||
viewModel { (artistId: String) ->
|
viewModel { (artistId: String) ->
|
||||||
ArtistViewModel(
|
ArtistViewModel(
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
pluginManager = get(),
|
repository = get(),
|
||||||
savedTracksRepository = get(),
|
|
||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
|
savedTracksRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -146,6 +146,7 @@ fun TrackList(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
||||||
simplified: Boolean = false,
|
simplified: Boolean = false,
|
||||||
|
scrollable: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
var filterQuery by rememberSaveable { mutableStateOf("") }
|
||||||
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
||||||
@ -207,145 +208,7 @@ fun TrackList(
|
|||||||
val showAlbum = !isCompact
|
val showAlbum = !isCompact
|
||||||
val useDropdownForOptions = isDesktop && !isCompact
|
val useDropdownForOptions = isDesktop && !isCompact
|
||||||
|
|
||||||
|
val trackCardContent: @Composable () -> Unit = {
|
||||||
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 {
|
|
||||||
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(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -466,6 +329,147 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (footerContent != null) {
|
||||||
item {
|
item {
|
||||||
footerContent()
|
footerContent()
|
||||||
@ -489,7 +493,40 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
|||||||
@ -125,6 +125,7 @@ open class ZiplinePluginService(
|
|||||||
}
|
}
|
||||||
private val lifecycleMutex = Mutex()
|
private val lifecycleMutex = Mutex()
|
||||||
private var ziplineLoader: ZiplineLoader
|
private var ziplineLoader: ZiplineLoader
|
||||||
|
private var ziplineInstance: Zipline? = null
|
||||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -271,6 +272,7 @@ open class ZiplinePluginService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||||
withContext(ziplineDispatcher.dispatcher) {
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
trace("start(): inside zipline dispatcher before loadOnce")
|
trace("start(): inside zipline dispatcher before loadOnce")
|
||||||
val result = ziplineLoader.loadOnce(
|
val result = ziplineLoader.loadOnce(
|
||||||
@ -280,6 +282,8 @@ open class ZiplinePluginService(
|
|||||||
)
|
)
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadResult.Success -> {
|
is LoadResult.Success -> {
|
||||||
|
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||||
|
ziplineInstance = result.zipline
|
||||||
// Now we consume the initializer
|
// Now we consume the initializer
|
||||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||||
// Bind host services before initialization, so plugins can use them in their initializer
|
// Bind host services before initialization, so plugins can use them in their initializer
|
||||||
@ -310,10 +314,19 @@ open class ZiplinePluginService(
|
|||||||
override suspend fun stop() {
|
override suspend fun stop() {
|
||||||
lifecycleMutex.withLock {
|
lifecycleMutex.withLock {
|
||||||
trace("stop(): entered")
|
trace("stop(): entered")
|
||||||
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
|
ziplineInstance?.close()
|
||||||
|
ziplineInstance = null
|
||||||
for (service in serviceRegistry.values) {
|
for (service in serviceRegistry.values) {
|
||||||
|
try {
|
||||||
trace("stop(): closing service ${service::class.simpleName}")
|
trace("stop(): closing service ${service::class.simpleName}")
|
||||||
service.close()
|
service.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
trace("stop(): error closing service ${service::class.simpleName}")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serviceRegistry.clear()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
loggedInStateFlow.value = false
|
loggedInStateFlow.value = false
|
||||||
ziplineDispatcher.close()
|
ziplineDispatcher.close()
|
||||||
|
|||||||
@ -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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@ -45,9 +46,11 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -59,12 +62,12 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import compose.icons.FeatherIcons
|
import compose.icons.FeatherIcons
|
||||||
import compose.icons.feathericons.Heart
|
|
||||||
import compose.icons.feathericons.Play
|
import compose.icons.feathericons.Play
|
||||||
import compose.icons.feathericons.PlusSquare
|
import compose.icons.feathericons.PlusSquare
|
||||||
import compose.icons.feathericons.User
|
import compose.icons.feathericons.User
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
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.MetadataArtist
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
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.TrackList
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
|
||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
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.SkeletonTree
|
||||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
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.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@ -101,9 +108,7 @@ fun ArtistScreen(artistId: String) {
|
|||||||
)
|
)
|
||||||
val navigationCommands = koinInject<NavigationCommands>()
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
|
|
||||||
val artistInfo by viewModel.artistInfo.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val topTracksState by viewModel.topTracks.collectAsStateWithLifecycle()
|
|
||||||
val albumsState by viewModel.albums.collectAsStateWithLifecycle()
|
|
||||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
||||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.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) {
|
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||||
viewModel.handleTrackOptionsAction(track, action)
|
viewModel.handleTrackOptionsAction(track, action)
|
||||||
if (action is TrackOptionsAction.Share) {
|
if (action is TrackOptionsAction.Share) {
|
||||||
@ -141,76 +144,227 @@ fun ArtistScreen(artistId: String) {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { ApplicationMainBar() }
|
topBar = { ApplicationMainBar() }
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
TrackList(
|
when (val currentState = state) {
|
||||||
modifier = Modifier.padding(innerPadding),
|
is ArtistScreenState.Loading -> {
|
||||||
headerContent = {
|
ArtistLoadingContent(innerPadding)
|
||||||
Column(
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
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),
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
ArtistHeaderCard(
|
ArtistHeaderCard(
|
||||||
artist = artist,
|
artist = currentState.artist,
|
||||||
isSaved = savedArtistIds.contains(artistId),
|
isSaved = savedArtistIds.contains(currentState.artist.id),
|
||||||
isLoading = artistInfo.isLoading,
|
|
||||||
isSaving = artistInfo.isSaving,
|
|
||||||
error = artistInfo.error,
|
|
||||||
onFollowClick = viewModel::toggleSavedArtist,
|
onFollowClick = viewModel::toggleSavedArtist,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ArtistAlbumsSection(
|
item {
|
||||||
albums = albumsState.items,
|
|
||||||
isLoading = albumsState.isLoading,
|
|
||||||
error = albumsState.error,
|
|
||||||
hasMore = albumsState.hasNextPage,
|
|
||||||
onViewAll = viewModel::loadNextAlbumsPage,
|
|
||||||
)
|
|
||||||
|
|
||||||
TopTracksHeader(
|
TopTracksHeader(
|
||||||
onPlay = viewModel::playTopTracks,
|
onPlay = viewModel::playTopTracks,
|
||||||
onAddToQueue = viewModel::addTopTracksToQueue,
|
onAddToQueue = viewModel::addTopTracksToQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
tracks = topTracksState.items,
|
item {
|
||||||
error = topTracksState.error,
|
TrackList(
|
||||||
|
tracks = currentState.topTracks,
|
||||||
|
error = null,
|
||||||
hasMore = false,
|
hasMore = false,
|
||||||
isLoading = topTracksState.isLoading && topTracksState.items.isEmpty(),
|
isLoading = false,
|
||||||
isLoadingNextPage = false,
|
isLoadingNextPage = false,
|
||||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||||
onTrackClick = viewModel::playTopTracksFromTrack,
|
onTrackClick = viewModel::playTopTracksFromTrack,
|
||||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
trackOptionsState = ::getTrackOptionsState,
|
||||||
onArtistClick = { trackArtist -> navigationCommands.navigateTo(Routes.Artist(trackArtist.id)) },
|
onArtistClick = { trackArtist ->
|
||||||
onAlbumClick = { album -> navigationCommands.navigateTo(Routes.Album(album.id)) },
|
navigationCommands.navigateTo(
|
||||||
onLoadNextPage = { },
|
Routes.Artist(
|
||||||
simplified = true,
|
trackArtist.id
|
||||||
onBulkDownload = { tracks ->
|
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
|
||||||
},
|
|
||||||
onBulkAddToQueue = { tracks ->
|
|
||||||
viewModel.addTracksToQueue(tracks)
|
|
||||||
},
|
|
||||||
onBulkPlayNext = { tracks ->
|
|
||||||
viewModel.playTracksNext(tracks)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
@Composable
|
||||||
private fun ArtistHeaderCard(
|
private fun ArtistHeaderCard(
|
||||||
artist: MetadataArtist.Detailed?,
|
artist: MetadataArtist.Detailed,
|
||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
isLoading: Boolean,
|
|
||||||
isSaving: Boolean,
|
|
||||||
error: String?,
|
|
||||||
onFollowClick: () -> Unit,
|
onFollowClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
val isCompact = maxWidth < 600.dp
|
val isCompact = maxWidth < 600.dp
|
||||||
|
|
||||||
SkeletonTree(isLoading = isLoading) {
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -240,17 +394,8 @@ private fun ArtistHeaderCard(
|
|||||||
|
|
||||||
ArtistHeaderActions(
|
ArtistHeaderActions(
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
isSaving = isSaving,
|
|
||||||
onFollowClick = onFollowClick,
|
onFollowClick = onFollowClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
error?.let {
|
|
||||||
TextWithShimmer(
|
|
||||||
text = it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(
|
Row(
|
||||||
@ -276,18 +421,8 @@ private fun ArtistHeaderCard(
|
|||||||
|
|
||||||
ArtistHeaderActions(
|
ArtistHeaderActions(
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
isSaving = isSaving,
|
|
||||||
onFollowClick = onFollowClick,
|
onFollowClick = onFollowClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
error?.let {
|
|
||||||
TextWithShimmer(
|
|
||||||
text = it,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,29 +432,28 @@ private fun ArtistHeaderCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistAvatar(
|
private fun ArtistAvatar(
|
||||||
artist: MetadataArtist.Detailed?,
|
artist: MetadataArtist.Detailed,
|
||||||
size: androidx.compose.ui.unit.Dp,
|
size: androidx.compose.ui.unit.Dp,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
.shimmerApply(),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
val imageUrl = artist?.thumbnails?.firstOrNull()?.url.orEmpty()
|
val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty()
|
||||||
if (imageUrl.isNotBlank()) {
|
if (imageUrl.isNotBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = artist?.name ?: "Artist",
|
contentDescription = artist.name,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = FeatherIcons.User,
|
imageVector = FeatherIcons.User,
|
||||||
contentDescription = artist?.name ?: "Artist",
|
contentDescription = artist.name,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(size * 0.4f),
|
modifier = Modifier.size(size * 0.4f),
|
||||||
)
|
)
|
||||||
@ -329,7 +463,7 @@ private fun ArtistAvatar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistMeta(
|
private fun ArtistMeta(
|
||||||
artist: MetadataArtist.Detailed?,
|
artist: MetadataArtist.Detailed,
|
||||||
isCompact: Boolean,
|
isCompact: Boolean,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@ -337,38 +471,36 @@ private fun ArtistMeta(
|
|||||||
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
|
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
TextWithShimmer(
|
Text(
|
||||||
text = artist?.name ?: "Loading artist...",
|
text = artist.name,
|
||||||
style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge,
|
style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
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,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
artist?.let {
|
Text(
|
||||||
TextWithShimmer(
|
|
||||||
text = buildString {
|
text = buildString {
|
||||||
append(formatFollowers(it.followersCount))
|
append(formatFollowers(artist.followersCount))
|
||||||
if (it.genres.isNotEmpty()) {
|
if (artist.genres.isNotEmpty()) {
|
||||||
append(" • ")
|
append(" • ")
|
||||||
append(it.genres.joinToString(", "))
|
append(artist.genres.joinToString(", "))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
artist?.biography?.takeIf { it.isNotBlank() }?.let {
|
artist.biography?.takeIf { it.isNotBlank() }?.let {
|
||||||
TextWithShimmer(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = if (isCompact) 4 else 6,
|
maxLines = if (isCompact) 4 else 6,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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
|
@Composable
|
||||||
private fun ArtistHeaderActions(
|
private fun ArtistHeaderActions(
|
||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
isSaving: Boolean,
|
|
||||||
onFollowClick: () -> Unit,
|
onFollowClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@ -385,91 +516,12 @@ private fun ArtistHeaderActions(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (isSaved) {
|
if (isSaved) {
|
||||||
FilledTonalButton(onClick = onFollowClick, enabled = !isSaving) {
|
FilledTonalButton(onClick = onFollowClick) {
|
||||||
TextWithShimmer("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(onClick = onFollowClick, enabled = !isSaving) {
|
Button(onClick = onFollowClick) {
|
||||||
TextWithShimmer("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
Text("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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -487,7 +539,7 @@ private fun TopTracksHeader(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
TextWithShimmer(
|
Text(
|
||||||
text = "Top Tracks",
|
text = "Top Tracks",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
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 {
|
private fun formatFollowers(count: Int?): String {
|
||||||
if (count == null) return "Followers unavailable"
|
if (count == null) return "Followers unavailable"
|
||||||
return when {
|
return when {
|
||||||
@ -529,4 +705,3 @@ private fun formatAbbreviatedCount(count: Int, divisor: Int): String {
|
|||||||
rounded.toString()
|
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.album.MetadataAlbum
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
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.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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
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.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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 kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
data class ArtistInfoState(
|
sealed interface ArtistScreenState {
|
||||||
val artist: MetadataArtist.Detailed? = null,
|
data object Loading : ArtistScreenState
|
||||||
val isSaved: Boolean = false,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val isSaving: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ArtistTopTracksState(
|
data class Loaded(
|
||||||
val items: List<MetadataTrack> = emptyList(),
|
val artist: MetadataArtist.Detailed,
|
||||||
val isLoading: Boolean = false,
|
val isArtistSaved: Boolean,
|
||||||
val error: String? = null,
|
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(
|
data class Error(val message: String) : ArtistScreenState
|
||||||
val items: List<MetadataAlbum.Detailed> = emptyList(),
|
}
|
||||||
val nextPagination: PaginationStrategy? = null,
|
|
||||||
val hasNextPage: Boolean = true,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
class ArtistViewModel(
|
class ArtistViewModel(
|
||||||
private val artistId: String,
|
private val artistId: String,
|
||||||
private val pluginManager: PluginManager,
|
private val repository: ArtistRepository,
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
) : ViewModel() {
|
) : ViewModel(), KoinComponent {
|
||||||
companion object {
|
private val logger by injectLogger<ArtistViewModel>()
|
||||||
private const val ALBUMS_PAGE_SIZE = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _artistInfo = MutableStateFlow(ArtistInfoState())
|
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
|
||||||
val artistInfo: StateFlow<ArtistInfoState> = _artistInfo.asStateFlow()
|
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
|
val savedArtistIds
|
||||||
get() = libraryRepository.savedArtistIdsFlow
|
get() = libraryRepository.savedArtistIdsFlow
|
||||||
|
|
||||||
|
val savedTrackIds
|
||||||
|
get() = savedTracksRepository.savedTracksIdsFlow
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
pluginManager.selectedMetadataPlugin
|
libraryRepository.savedArtistIdsFlow.collect {
|
||||||
.filterNotNull()
|
val currentState = _state.value
|
||||||
.distinctUntilChanged()
|
if (currentState is ArtistScreenState.Loaded) {
|
||||||
.flatMapLatest { it.loggedInFlow }
|
_state.value = currentState.copy(isArtistSaved = it.contains(artistId))
|
||||||
.collect {
|
|
||||||
_artistInfo.value = ArtistInfoState()
|
|
||||||
_topTracks.value = ArtistTopTracksState()
|
|
||||||
_albums.value = ArtistAlbumsState()
|
|
||||||
|
|
||||||
loadArtistInfo()
|
|
||||||
loadTopTracks()
|
|
||||||
loadAlbumsPage(reset = true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadOverview()
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshArtist() {
|
fun refresh() {
|
||||||
viewModelScope.launch {
|
repository.invalidateCaches()
|
||||||
loadArtistInfo()
|
loadOverview()
|
||||||
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() {
|
fun toggleSavedArtist() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val isLiked =
|
val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
|
||||||
libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
|
|
||||||
if (isLiked) {
|
if (isLiked) {
|
||||||
libraryRepository.removeSavedArtists(listOf(artistId))
|
libraryRepository.removeSavedArtists(listOf(artistId))
|
||||||
} else {
|
} 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() {
|
fun addTopTracksToQueue() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val entries = resolveTopTrackEntries()
|
val entries = resolveTopTrackEntries()
|
||||||
if (entries.isEmpty()) return@launch
|
if (entries.isEmpty()) return@launch
|
||||||
|
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,7 +176,6 @@ class ArtistViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val entries = resolveTopTrackEntries()
|
val entries = resolveTopTrackEntries()
|
||||||
if (entries.isEmpty()) return@launch
|
if (entries.isEmpty()) return@launch
|
||||||
|
|
||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
@ -180,128 +212,17 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadArtistInfo() {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
val plugin = pluginManager.selectedMetadataPlugin.value
|
viewModelScope.launch {
|
||||||
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
if (plugin == null) {
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
_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",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadTopTracks() {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
val plugin = pluginManager.selectedMetadataPlugin.value
|
viewModelScope.launch {
|
||||||
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
if (plugin == null) {
|
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||||
_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 = "")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,28 +273,47 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
private fun loadOverview() {
|
||||||
|
_state.value = ArtistScreenState.Loading
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
runCatching {
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
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>) {
|
private fun resolveTopTrackEntries(): List<QueueEntry> {
|
||||||
viewModelScope.launch {
|
val currentState = _state.value
|
||||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
if (currentState !is ArtistScreenState.Loaded) return emptyList()
|
||||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
return currentState.topTracks.map { track ->
|
||||||
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val savedTrackIds
|
|
||||||
get() = savedTracksRepository.savedTracksIdsFlow
|
|
||||||
|
|
||||||
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
||||||
if (id.isNotBlank() && other.id.isNotBlank()) {
|
if (id.isNotBlank() && other.id.isNotBlank()) {
|
||||||
return id == other.id
|
return id == other.id
|
||||||
}
|
}
|
||||||
|
|
||||||
return title == other.title &&
|
return title == other.title &&
|
||||||
durationMs == other.durationMs &&
|
durationMs == other.durationMs &&
|
||||||
album?.id == other.album?.id &&
|
album?.id == other.album?.id &&
|
||||||
|
|||||||
@ -49,8 +49,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.MetadataBrowseItem
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||||
import dev.krtirtho.spotube.PlatformType
|
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.AlbumCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||||
@ -80,6 +83,15 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val selectedGenreId = when (val s = state) {
|
||||||
|
is HomeScreenState.Data -> s.selectedGenreId
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(selectedGenreId) {
|
||||||
|
listState.scrollToItem(0)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(listState) {
|
LaunchedEffect(listState) {
|
||||||
snapshotFlow { listState.layoutInfo }
|
snapshotFlow { listState.layoutInfo }
|
||||||
.map { layoutInfo ->
|
.map { layoutInfo ->
|
||||||
@ -119,6 +131,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
state = state,
|
state = state,
|
||||||
onRetry = { viewModel.refresh() },
|
onRetry = { viewModel.refresh() },
|
||||||
|
onGenreSelected = { viewModel.selectGenre(it) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
@ -132,6 +145,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
state = state,
|
state = state,
|
||||||
onRetry = { viewModel.refresh() },
|
onRetry = { viewModel.refresh() },
|
||||||
|
onGenreSelected = { viewModel.selectGenre(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -150,6 +164,7 @@ private fun HomeContent(
|
|||||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
state: HomeScreenState,
|
state: HomeScreenState,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onGenreSelected: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val shellBottomInset = LocalAppShellBottomInset.current
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
val contentPadding = remember(shellBottomInset) {
|
val contentPadding = remember(shellBottomInset) {
|
||||||
@ -216,13 +231,55 @@ private fun HomeContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(state.browseSections) { section ->
|
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 ->
|
||||||
HomeSection(
|
HomeSection(
|
||||||
title = section.title,
|
title = section.title,
|
||||||
subtitle = section.description,
|
subtitle = section.description,
|
||||||
items = section.items,
|
items = section.items,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state is HomeScreenState.Data.LoadingMore) {
|
if (state is HomeScreenState.Data.LoadingMore) {
|
||||||
item {
|
item {
|
||||||
@ -307,3 +364,26 @@ 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,6 +17,7 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.home
|
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.MetadataBrowseItem
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||||
@ -45,8 +46,10 @@ class HomeScreenRepository(
|
|||||||
get() = pluginManager.selectedMetadataPlugin.value
|
get() = pluginManager.selectedMetadataPlugin.value
|
||||||
|
|
||||||
private val featuredItemCache = Cache.Builder<String, List<MetadataBrowseItem>>().build()
|
private val featuredItemCache = Cache.Builder<String, List<MetadataBrowseItem>>().build()
|
||||||
|
private val genresCache = Cache.Builder<String, List<MetadataBrowseGenre>>().build()
|
||||||
private val browseItemCache =
|
private val browseItemCache =
|
||||||
Cache.Builder<PaginationStrategy, PaginationResult<MetadataBrowseSection>>().build()
|
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseSection>>()
|
||||||
|
.build()
|
||||||
private val sublistItemCache =
|
private val sublistItemCache =
|
||||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>()
|
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>()
|
||||||
.build()
|
.build()
|
||||||
@ -65,6 +68,7 @@ class HomeScreenRepository(
|
|||||||
|
|
||||||
fun invalidateCaches() {
|
fun invalidateCaches() {
|
||||||
featuredItemCache.invalidateAll()
|
featuredItemCache.invalidateAll()
|
||||||
|
genresCache.invalidateAll()
|
||||||
browseItemCache.invalidateAll()
|
browseItemCache.invalidateAll()
|
||||||
sublistItemCache.invalidateAll()
|
sublistItemCache.invalidateAll()
|
||||||
}
|
}
|
||||||
@ -79,32 +83,43 @@ class HomeScreenRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun list(paginationStrategy: PaginationStrategy? = null) =
|
suspend fun genres() = plugin?.let { plugin ->
|
||||||
|
genresCache.get("genres") {
|
||||||
|
pluginManager.withScope {
|
||||||
|
plugin.use {
|
||||||
|
metadataBrowseAPI.genres()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun list(genreId: String, paginationStrategy: PaginationStrategy? = null) =
|
||||||
plugin?.let { plugin ->
|
plugin?.let { plugin ->
|
||||||
browseItemCache.get(
|
browseItemCache.get(
|
||||||
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
|
key = genreId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
|
||||||
) {
|
) {
|
||||||
pluginManager.withScope {
|
pluginManager.withScope {
|
||||||
plugin.use {
|
plugin.use {
|
||||||
metadataBrowseAPI.list(paginationStrategy)
|
metadataBrowseAPI.list(genreId, paginationStrategy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sublist(
|
suspend fun sublist(
|
||||||
|
genreId: String,
|
||||||
parentId: String,
|
parentId: String,
|
||||||
paginationStrategy: PaginationStrategy? = null
|
paginationStrategy: PaginationStrategy? = null
|
||||||
) = plugin?.let { plugin ->
|
) = plugin?.let { plugin ->
|
||||||
sublistItemCache.get(
|
sublistItemCache.get(
|
||||||
parentId to (paginationStrategy ?: PaginationStrategy.Offset(
|
"$genreId/$parentId" to (paginationStrategy ?: PaginationStrategy.Offset(
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
pluginManager.withScope {
|
pluginManager.withScope {
|
||||||
plugin.use {
|
plugin.use {
|
||||||
metadataBrowseAPI.sublist(parentId, paginationStrategy)
|
metadataBrowseAPI.sublist(genreId, parentId, paginationStrategy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.home
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.MetadataBrowseItem
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
||||||
@ -27,19 +28,11 @@ import dev.krtirtho.spotube.core.di.injectLogger
|
|||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
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 kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
@ -48,29 +41,33 @@ sealed interface HomeScreenState {
|
|||||||
|
|
||||||
sealed interface Data : HomeScreenState {
|
sealed interface Data : HomeScreenState {
|
||||||
val featuredItems: List<MetadataBrowseItem>
|
val featuredItems: List<MetadataBrowseItem>
|
||||||
val browseSections: List<MetadataBrowseSection>
|
val genres: List<MetadataBrowseGenre>
|
||||||
val paginationStrategy: PaginationStrategy?
|
val selectedGenreId: String?
|
||||||
val totalItems: Int
|
val browseSections: Map<String, List<MetadataBrowseSection>?>
|
||||||
|
val paginationStrategies: Map<String, PaginationStrategy?>
|
||||||
|
|
||||||
data class Loaded(
|
data class Loaded(
|
||||||
override val featuredItems: List<MetadataBrowseItem>,
|
override val featuredItems: List<MetadataBrowseItem>,
|
||||||
override val browseSections: List<MetadataBrowseSection>,
|
override val genres: List<MetadataBrowseGenre>,
|
||||||
override val paginationStrategy: PaginationStrategy?,
|
override val selectedGenreId: String?,
|
||||||
override val totalItems: Int,
|
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
|
||||||
|
override val paginationStrategies: Map<String, PaginationStrategy?>,
|
||||||
) : Data {
|
) : Data {
|
||||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||||
featuredItems = featuredItems,
|
featuredItems = featuredItems,
|
||||||
|
genres = genres,
|
||||||
|
selectedGenreId = selectedGenreId,
|
||||||
browseSections = browseSections,
|
browseSections = browseSections,
|
||||||
paginationStrategy = paginationStrategy,
|
paginationStrategies = paginationStrategies,
|
||||||
totalItems = totalItems,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoadingMore(
|
data class LoadingMore(
|
||||||
override val featuredItems: List<MetadataBrowseItem>,
|
override val featuredItems: List<MetadataBrowseItem>,
|
||||||
override val browseSections: List<MetadataBrowseSection>,
|
override val genres: List<MetadataBrowseGenre>,
|
||||||
override val paginationStrategy: PaginationStrategy?,
|
override val selectedGenreId: String?,
|
||||||
override val totalItems: Int,
|
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
|
||||||
|
override val paginationStrategies: Map<String, PaginationStrategy?>,
|
||||||
) : Data
|
) : Data
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,30 +96,67 @@ class HomeScreenViewModel(
|
|||||||
|
|
||||||
private suspend fun loadInitialData() = runCatching {
|
private suspend fun loadInitialData() = runCatching {
|
||||||
state.value = HomeScreenState.Loading
|
state.value = HomeScreenState.Loading
|
||||||
val featuredItems = repository.featuredItems()
|
val featuredItems = repository.featuredItems() ?: emptyList()
|
||||||
val browseSections = repository.list()
|
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
|
||||||
|
}
|
||||||
state.value = HomeScreenState.Data.Loaded(
|
state.value = HomeScreenState.Data.Loaded(
|
||||||
featuredItems = featuredItems ?: emptyList(),
|
featuredItems = featuredItems,
|
||||||
browseSections = browseSections?.items ?: emptyList(),
|
genres = genres,
|
||||||
paginationStrategy = browseSections?.nextPagination,
|
selectedGenreId = firstGenreId,
|
||||||
totalItems = browseSections?.items?.size ?: 0,
|
browseSections = browseSections,
|
||||||
|
paginationStrategies = paginationStrategies,
|
||||||
)
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load home screen data" }
|
logger.e(e) { "Failed to load home screen data" }
|
||||||
state.value = HomeScreenState.Error(e.message ?: "Unknown error")
|
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 {
|
suspend fun loadMoreData() = runCatching {
|
||||||
val currentState = state.value
|
val currentState = state.value
|
||||||
if (currentState is HomeScreenState.Data.Loaded && currentState.paginationStrategy != null) {
|
if (currentState is HomeScreenState.Data.Loaded) {
|
||||||
|
val genreId = currentState.selectedGenreId ?: return@runCatching
|
||||||
|
val pagination = currentState.paginationStrategies[genreId] ?: return@runCatching
|
||||||
state.value = currentState.toLoadingMore()
|
state.value = currentState.toLoadingMore()
|
||||||
val browseSectionsResult = repository.list(currentState.paginationStrategy)
|
val result = repository.list(genreId, pagination)
|
||||||
|
val existingSections = currentState.browseSections[genreId] ?: emptyList()
|
||||||
state.value = HomeScreenState.Data.Loaded(
|
state.value = HomeScreenState.Data.Loaded(
|
||||||
featuredItems = currentState.featuredItems,
|
featuredItems = currentState.featuredItems,
|
||||||
browseSections = currentState.browseSections + (browseSectionsResult?.items
|
genres = currentState.genres,
|
||||||
?: emptyList()),
|
selectedGenreId = genreId,
|
||||||
paginationStrategy = browseSectionsResult?.nextPagination,
|
browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))),
|
||||||
totalItems = currentState.totalItems,
|
paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitCancellation
|
import kotlinx.coroutines.awaitCancellation
|
||||||
@ -147,9 +148,13 @@ class PluginManager(
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val ziplineServices = state
|
val ziplineServices = state
|
||||||
.filterIsInstance<PluginManagerStates.Data>()
|
.filterIsInstance<PluginManagerStates.Data>()
|
||||||
.map { it.selectedPlugins }
|
.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.
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flatMapLatest { plugins ->
|
.flatMapLatest { (plugins, gen) ->
|
||||||
|
logger.d { "ziplineServices: flow restarting with generation=$gen, selectedPlugins=${plugins.keys}" }
|
||||||
flow {
|
flow {
|
||||||
val ziplineServices: MutableMap<PluginAbility, PluginService?> =
|
val ziplineServices: MutableMap<PluginAbility, PluginService?> =
|
||||||
mutableMapOf()
|
mutableMapOf()
|
||||||
@ -166,6 +171,7 @@ class PluginManager(
|
|||||||
ziplineServicesByPluginID[plugin.id] = service
|
ziplineServicesByPluginID[plugin.id] = service
|
||||||
service.start()
|
service.start()
|
||||||
} else {
|
} else {
|
||||||
|
logger.d { "ziplineServices: creating new ZiplinePluginService for ${plugin.name} (${plugin.id})" }
|
||||||
val service = ZiplinePluginService(
|
val service = ZiplinePluginService(
|
||||||
applicationName = plugin.name,
|
applicationName = plugin.name,
|
||||||
manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}",
|
manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}",
|
||||||
@ -180,13 +186,17 @@ class PluginManager(
|
|||||||
// Keep the flow alive until the next plugin is selected.
|
// Keep the flow alive until the next plugin is selected.
|
||||||
awaitCancellation()
|
awaitCancellation()
|
||||||
} finally {
|
} finally {
|
||||||
// Stop services that are no longer selected
|
withContext(NonCancellable) {
|
||||||
val selectedPluginIDs = plugins.values.map { it.id }.toSet()
|
logger.d { "ziplineServices: finally block stopping ${ziplineServicesByPluginID.size} services" }
|
||||||
ziplineServicesByPluginID.forEach { (pluginID, service) ->
|
ziplineServicesByPluginID.forEach { (_, service) ->
|
||||||
if (!selectedPluginIDs.contains(pluginID)) {
|
try {
|
||||||
service.stop()
|
service.stop()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
logger.e { "ziplineServices: error stopping service" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.d { "ziplineServices: all services stopped" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -495,7 +505,12 @@ class PluginManager(
|
|||||||
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
||||||
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
|
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
|
||||||
}
|
}
|
||||||
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins)
|
val newState = PluginManagerStates.Data(
|
||||||
|
plugins = updatedPlugins,
|
||||||
|
selectedPlugins = updatedSelectedPlugins,
|
||||||
|
generation = currentState.generation + 1
|
||||||
|
)
|
||||||
|
logger.d { "addPlugin: bumped generation to ${newState.generation} for plugin ${plugin.id}" }
|
||||||
updatePluginsState(newState)
|
updatePluginsState(newState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -510,7 +525,11 @@ class PluginManager(
|
|||||||
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
|
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
|
||||||
val updatedSelectedPlugins =
|
val updatedSelectedPlugins =
|
||||||
currentState.selectedPlugins.filterValues { it.id != plugin.id }
|
currentState.selectedPlugins.filterValues { it.id != plugin.id }
|
||||||
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins)
|
val newState = PluginManagerStates.Data(
|
||||||
|
plugins = updatedPlugins,
|
||||||
|
selectedPlugins = updatedSelectedPlugins,
|
||||||
|
generation = currentState.generation
|
||||||
|
)
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val pluginDir = pluginsDir / plugin.id.toPath()
|
val pluginDir = pluginsDir / plugin.id.toPath()
|
||||||
|
|||||||
@ -55,7 +55,12 @@ sealed class PluginManagerStates {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class Data(
|
data class Data(
|
||||||
val plugins: List<PluginEntry>,
|
val plugins: List<PluginEntry>,
|
||||||
val selectedPlugins: Map<PluginAbility, PluginEntry> = emptyMap()
|
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
|
||||||
) : PluginManagerStates()
|
) : PluginManagerStates()
|
||||||
|
|
||||||
data object Loading : PluginManagerStates()
|
data object Loading : PluginManagerStates()
|
||||||
|
|||||||
@ -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.album.MetadataAlbum
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
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.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.PaginationStrategy
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
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
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
|
||||||
class RealMetadataArtistAPI : MetadataArtistAPI {
|
class RealMetadataArtistAPI : MetadataArtistAPI {
|
||||||
@ -29,10 +31,28 @@ class RealMetadataArtistAPI : MetadataArtistAPI {
|
|||||||
return FakeMetadataStore.getArtist(id)
|
return FakeMetadataStore.getArtist(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun artistOverview(id: String): MetadataArtistOverview {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> {
|
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> {
|
||||||
return FakeMetadataStore.getArtistTopTracks(id)
|
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(
|
override suspend fun getArtistAlbums(
|
||||||
id: String,
|
id: String,
|
||||||
pagination: PaginationStrategy?
|
pagination: PaginationStrategy?
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
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.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.MetadataBrowseItem
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
||||||
@ -28,11 +29,22 @@ class RealMetadataBrowseAPI : MetadataBrowseAPI {
|
|||||||
return FakeMetadataStore.getFeaturedItems()
|
return FakeMetadataStore.getFeaturedItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun list(pagination: PaginationStrategy?): PaginationResult<MetadataBrowseSection> {
|
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> {
|
||||||
return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSections(), pagination)
|
return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSections(), pagination)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sublist(
|
override suspend fun sublist(
|
||||||
|
genreId: String,
|
||||||
sectionId: String,
|
sectionId: String,
|
||||||
pagination: PaginationStrategy?
|
pagination: PaginationStrategy?
|
||||||
): PaginationResult<MetadataBrowseItem> {
|
): PaginationResult<MetadataBrowseItem> {
|
||||||
|
|||||||
@ -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.album.MetadataAlbum
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
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.common.PaginationResult
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
|
||||||
const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI"
|
const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI"
|
||||||
|
|
||||||
interface MetadataArtistAPI : ZiplineService {
|
interface MetadataArtistAPI : ZiplineService {
|
||||||
suspend fun getArtist(id: String): MetadataArtist.Detailed
|
suspend fun getArtist(id: String): MetadataArtist.Detailed
|
||||||
|
suspend fun artistOverview(id: String): MetadataArtistOverview
|
||||||
suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack>
|
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(
|
suspend fun getArtistAlbums(
|
||||||
id: String,
|
id: String,
|
||||||
pagination: PaginationStrategy? = null
|
pagination: PaginationStrategy? = null
|
||||||
|
|||||||
@ -16,7 +16,11 @@
|
|||||||
|
|
||||||
package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist
|
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.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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@ -48,3 +52,12 @@ sealed class MetadataArtist {
|
|||||||
override val externalUri: String?
|
override val externalUri: String?
|
||||||
) : MetadataArtist()
|
) : 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,6 +24,7 @@ const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI"
|
|||||||
|
|
||||||
interface MetadataBrowseAPI: ZiplineService {
|
interface MetadataBrowseAPI: ZiplineService {
|
||||||
suspend fun featured(): List<MetadataBrowseItem>
|
suspend fun featured(): List<MetadataBrowseItem>
|
||||||
suspend fun list(pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
|
suspend fun genres(): List<MetadataBrowseGenre>
|
||||||
suspend fun sublist(sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
|
suspend fun list(genreId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
|
||||||
|
suspend fun sublist(genreId: String, sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
|
||||||
}
|
}
|
||||||
@ -54,3 +54,9 @@ data class MetadataBrowseSection(
|
|||||||
val items: List<MetadataBrowseItem>,
|
val items: List<MetadataBrowseItem>,
|
||||||
val moreLink: String? = null
|
val moreLink: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MetadataBrowseGenre(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user