mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-06 04:09:51 +00:00
Compare commits
No commits in common. "f012defd7991d9d9760084ab87f426f88b530560" and "7d4d11705aef9d118e7a59e6d796b157e469c64e" have entirely different histories.
f012defd79
...
7d4d11705a
@ -29,7 +29,6 @@ import dev.krtirtho.spotube.core.server.MatchedTracksRepository
|
|||||||
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
|
import dev.krtirtho.spotube.core.server.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
|
||||||
@ -128,13 +127,12 @@ val sharedModules = module {
|
|||||||
viewModelOf(::SavedTracksViewModel)
|
viewModelOf(::SavedTracksViewModel)
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
singleOf(::ArtistRepository)
|
|
||||||
viewModel { (artistId: String) ->
|
viewModel { (artistId: String) ->
|
||||||
ArtistViewModel(
|
ArtistViewModel(
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
repository = get(),
|
pluginManager = get(),
|
||||||
libraryRepository = get(),
|
|
||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
|
libraryRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -146,7 +146,6 @@ 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) }
|
||||||
@ -208,128 +207,24 @@ fun TrackList(
|
|||||||
val showAlbum = !isCompact
|
val showAlbum = !isCompact
|
||||||
val useDropdownForOptions = isDesktop && !isCompact
|
val useDropdownForOptions = isDesktop && !isCompact
|
||||||
|
|
||||||
val trackCardContent: @Composable () -> Unit = {
|
|
||||||
Card(
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.widthIn(max = 1280.dp)
|
||||||
.padding(vertical = 8.dp),
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
if (headerContent != null) {
|
||||||
modifier = Modifier
|
item {
|
||||||
.fillMaxWidth()
|
headerContent()
|
||||||
.padding(top = 12.dp, bottom = 12.dp)
|
|
||||||
) {
|
|
||||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
|
||||||
if (displayedIndex > 0) {
|
|
||||||
HorizontalDivider(
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TrackListRow(
|
|
||||||
index = displayedIndex + 1,
|
|
||||||
track = track,
|
|
||||||
showIndex = showIndex,
|
|
||||||
showAlbum = showAlbum,
|
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
|
||||||
isCurrentTrack = track.id == currentTrackId,
|
|
||||||
isCurrentTrackPlaying = isCurrentTrackPlaying,
|
|
||||||
isSelectionMode = isSelectionMode,
|
|
||||||
isSelected = selectedTrackIds.contains(track.id),
|
|
||||||
onTrackClick = {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
selectedTrackIds =
|
|
||||||
if (selectedTrackIds.contains(track.id)) {
|
|
||||||
selectedTrackIds - track.id
|
|
||||||
} else {
|
|
||||||
selectedTrackIds + track.id
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onTrackClick(track)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
if (!useDropdownForOptions && !isSelectionMode) {
|
|
||||||
isSelectionMode = true
|
|
||||||
selectedTrackIds = setOf(track.id)
|
|
||||||
} else if (!useDropdownForOptions) {
|
|
||||||
selectedTrackForOptions = track
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelectionToggle = { checked ->
|
|
||||||
isSelectionMode = true
|
|
||||||
selectedTrackIds = if (checked) {
|
|
||||||
selectedTrackIds + track.id
|
|
||||||
} else {
|
|
||||||
selectedTrackIds - track.id
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTrackOptionsAction = { action ->
|
|
||||||
onTrackOptionsAction(
|
|
||||||
track,
|
|
||||||
action
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trackOptionsState = trackOptionsState(track),
|
|
||||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
|
||||||
onArtistClick = onArtistClick,
|
|
||||||
onAlbumClick = onAlbumClick,
|
|
||||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading && tracks.isEmpty()) {
|
|
||||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
|
||||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ShimmerTrackListRow(
|
|
||||||
index = shimmerIndex + 1,
|
|
||||||
showIndex = showIndex,
|
|
||||||
showAlbum = showAlbum,
|
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!simplified)
|
||||||
if (error != null) {
|
item {
|
||||||
if (visibleTracks.isNotEmpty()) {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TrackListFeedbackRow(
|
|
||||||
message = error,
|
|
||||||
isError = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
|
||||||
TrackListFeedbackRow("No tracks found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoadingNextPage) {
|
|
||||||
if (visibleTracks.isNotEmpty()) {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ShimmerTrackListRow(
|
|
||||||
index = visibleTracks.size + 1,
|
|
||||||
showIndex = showIndex,
|
|
||||||
showAlbum = showAlbum,
|
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val filterSortRow: @Composable () -> Unit = {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||||
.heightIn(max = 60.dp),
|
.heightIn(max = 60.dp),
|
||||||
@ -450,26 +345,127 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollable) {
|
item(key = "track-card") {
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
Card(
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 1280.dp)
|
.fillMaxWidth()
|
||||||
.align(Alignment.TopCenter)
|
.padding(vertical = 8.dp),
|
||||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
|
||||||
contentPadding = contentPadding,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
) {
|
) {
|
||||||
if (headerContent != null) {
|
Column(
|
||||||
item {
|
modifier = Modifier
|
||||||
headerContent()
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp, bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||||
|
if (displayedIndex > 0) {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TrackListRow(
|
||||||
|
index = displayedIndex + 1,
|
||||||
|
track = track,
|
||||||
|
showIndex = showIndex,
|
||||||
|
showAlbum = showAlbum,
|
||||||
|
useDropdownForOptions = useDropdownForOptions,
|
||||||
|
isCurrentTrack = track.id == currentTrackId,
|
||||||
|
isCurrentTrackPlaying = isCurrentTrackPlaying,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
isSelected = selectedTrackIds.contains(track.id),
|
||||||
|
onTrackClick = {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
selectedTrackIds =
|
||||||
|
if (selectedTrackIds.contains(track.id)) {
|
||||||
|
selectedTrackIds - track.id
|
||||||
|
} else {
|
||||||
|
selectedTrackIds + track.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onTrackClick(track)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (!useDropdownForOptions && !isSelectionMode) {
|
||||||
|
isSelectionMode = true
|
||||||
|
selectedTrackIds = setOf(track.id)
|
||||||
|
} else if (!useDropdownForOptions) {
|
||||||
|
selectedTrackForOptions = track
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelectionToggle = { checked ->
|
||||||
|
isSelectionMode = true
|
||||||
|
selectedTrackIds = if (checked) {
|
||||||
|
selectedTrackIds + track.id
|
||||||
|
} else {
|
||||||
|
selectedTrackIds - track.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTrackOptionsAction = { action ->
|
||||||
|
onTrackOptionsAction(
|
||||||
|
track,
|
||||||
|
action
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trackOptionsState = trackOptionsState(track),
|
||||||
|
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && tracks.isEmpty()) {
|
||||||
|
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||||
|
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShimmerTrackListRow(
|
||||||
|
index = shimmerIndex + 1,
|
||||||
|
showIndex = showIndex,
|
||||||
|
showAlbum = showAlbum,
|
||||||
|
useDropdownForOptions = useDropdownForOptions,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!simplified) {
|
|
||||||
item { filterSortRow() }
|
if (error != null) {
|
||||||
|
if (visibleTracks.isNotEmpty()) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
item(key = "track-card") { trackCardContent() }
|
TrackListFeedbackRow(
|
||||||
|
message = error,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||||
|
TrackListFeedbackRow("No tracks found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingNextPage) {
|
||||||
|
if (visibleTracks.isNotEmpty()) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShimmerTrackListRow(
|
||||||
|
index = visibleTracks.size + 1,
|
||||||
|
showIndex = showIndex,
|
||||||
|
showAlbum = showAlbum,
|
||||||
|
useDropdownForOptions = useDropdownForOptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (footerContent != null) {
|
if (footerContent != null) {
|
||||||
item {
|
item {
|
||||||
footerContent()
|
footerContent()
|
||||||
@ -493,40 +489,7 @@ 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,7 +125,6 @@ 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 {
|
||||||
@ -272,7 +271,6 @@ 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(
|
||||||
@ -282,8 +280,6 @@ 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
|
||||||
@ -314,19 +310,10 @@ 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()
|
||||||
|
|||||||
@ -1,140 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.artist
|
|
||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
|
||||||
import io.github.reactivecircus.cache4k.Cache
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class ArtistRepository(
|
|
||||||
private val pluginManager: PluginManager
|
|
||||||
) {
|
|
||||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
|
||||||
|
|
||||||
val plugin
|
|
||||||
get() = pluginManager.selectedMetadataPlugin.value
|
|
||||||
|
|
||||||
private val overviewCache = Cache.Builder<String, MetadataArtistOverview>().build()
|
|
||||||
private val albumsCache =
|
|
||||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataAlbum.Detailed>>().build()
|
|
||||||
private val relatedArtistsCache =
|
|
||||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataArtist.Basic>>().build()
|
|
||||||
private val featuredPlaylistsCache =
|
|
||||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataPlaylist>>().build()
|
|
||||||
private val topTracksCache = Cache.Builder<String, List<MetadataTrack>>().build()
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
pluginManager.selectedMetadataPlugin
|
|
||||||
.filterNotNull()
|
|
||||||
.flatMapLatest { it.loggedInFlow }
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect {
|
|
||||||
invalidateCaches()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invalidateCaches() {
|
|
||||||
overviewCache.invalidateAll()
|
|
||||||
albumsCache.invalidateAll()
|
|
||||||
relatedArtistsCache.invalidateAll()
|
|
||||||
featuredPlaylistsCache.invalidateAll()
|
|
||||||
topTracksCache.invalidateAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun artistOverview(artistId: String) = plugin?.let { plugin ->
|
|
||||||
overviewCache.get(artistId) {
|
|
||||||
pluginManager.withScope {
|
|
||||||
plugin.use {
|
|
||||||
metadataArtistAPI.artistOverview(artistId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun topTracks(artistId: String) = plugin?.let { plugin ->
|
|
||||||
topTracksCache.get(artistId) {
|
|
||||||
pluginManager.withScope {
|
|
||||||
plugin.use {
|
|
||||||
metadataArtistAPI.getArtistTop10Tracks(artistId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun albums(
|
|
||||||
artistId: String,
|
|
||||||
paginationStrategy: PaginationStrategy? = null
|
|
||||||
) = plugin?.let { plugin ->
|
|
||||||
albumsCache.get(
|
|
||||||
artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
|
|
||||||
) {
|
|
||||||
pluginManager.withScope {
|
|
||||||
plugin.use {
|
|
||||||
metadataArtistAPI.getArtistAlbums(artistId, paginationStrategy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun relatedArtists(
|
|
||||||
artistId: String,
|
|
||||||
paginationStrategy: PaginationStrategy? = null
|
|
||||||
) = plugin?.let { plugin ->
|
|
||||||
relatedArtistsCache.get(
|
|
||||||
artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
|
|
||||||
) {
|
|
||||||
pluginManager.withScope {
|
|
||||||
plugin.use {
|
|
||||||
metadataArtistAPI.relatedArtists(artistId, paginationStrategy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun featuredPlaylists(
|
|
||||||
artistId: String,
|
|
||||||
paginationStrategy: PaginationStrategy? = null
|
|
||||||
) = plugin?.let { plugin ->
|
|
||||||
featuredPlaylistsCache.get(
|
|
||||||
artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
|
|
||||||
) {
|
|
||||||
pluginManager.withScope {
|
|
||||||
plugin.use {
|
|
||||||
metadataArtistAPI.featuredPlaylists(artistId, paginationStrategy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -24,13 +24,12 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.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
|
||||||
@ -46,11 +45,9 @@ 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
|
||||||
@ -62,12 +59,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
|
||||||
@ -78,19 +75,15 @@ 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 kotlinx.coroutines.flow.distinctUntilChanged
|
import dev.krtirtho.spotube.modules.library.LibraryRepository
|
||||||
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
|
||||||
@ -108,7 +101,9 @@ fun ArtistScreen(artistId: String) {
|
|||||||
)
|
)
|
||||||
val navigationCommands = koinInject<NavigationCommands>()
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val artistInfo by viewModel.artistInfo.collectAsStateWithLifecycle()
|
||||||
|
val topTracksState by viewModel.topTracks.collectAsStateWithLifecycle()
|
||||||
|
val albumsState by viewModel.albums.collectAsStateWithLifecycle()
|
||||||
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
|
val 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()
|
||||||
@ -128,6 +123,8 @@ 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) {
|
||||||
@ -144,227 +141,76 @@ fun ArtistScreen(artistId: String) {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { ApplicationMainBar() }
|
topBar = { ApplicationMainBar() }
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
when (val currentState = state) {
|
TrackList(
|
||||||
is ArtistScreenState.Loading -> {
|
modifier = Modifier.padding(innerPadding),
|
||||||
ArtistLoadingContent(innerPadding)
|
headerContent = {
|
||||||
}
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
is ArtistScreenState.Error -> {
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
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 = currentState.artist,
|
artist = artist,
|
||||||
isSaved = savedArtistIds.contains(currentState.artist.id),
|
isSaved = savedArtistIds.contains(artistId),
|
||||||
|
isLoading = artistInfo.isLoading,
|
||||||
|
isSaving = artistInfo.isSaving,
|
||||||
|
error = artistInfo.error,
|
||||||
onFollowClick = viewModel::toggleSavedArtist,
|
onFollowClick = viewModel::toggleSavedArtist,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
ArtistAlbumsSection(
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
item {
|
tracks = topTracksState.items,
|
||||||
TrackList(
|
error = topTracksState.error,
|
||||||
tracks = currentState.topTracks,
|
|
||||||
error = null,
|
|
||||||
hasMore = false,
|
hasMore = false,
|
||||||
isLoading = false,
|
isLoading = topTracksState.isLoading && topTracksState.items.isEmpty(),
|
||||||
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 ->
|
onArtistClick = { trackArtist -> navigationCommands.navigateTo(Routes.Artist(trackArtist.id)) },
|
||||||
navigationCommands.navigateTo(
|
onAlbumClick = { album -> navigationCommands.navigateTo(Routes.Album(album.id)) },
|
||||||
Routes.Artist(
|
onLoadNextPage = { },
|
||||||
trackArtist.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onAlbumClick = { album ->
|
|
||||||
navigationCommands.navigateTo(
|
|
||||||
Routes.Album(
|
|
||||||
album.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onLoadNextPage = {},
|
|
||||||
simplified = true,
|
simplified = true,
|
||||||
scrollable = false,
|
onBulkDownload = { tracks ->
|
||||||
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(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() },
|
onBulkAddToQueue = { tracks ->
|
||||||
)
|
viewModel.addTracksToQueue(tracks)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) {
|
|
||||||
item {
|
|
||||||
RelatedArtistsSection(
|
|
||||||
artists = currentState.relatedArtists,
|
|
||||||
onArtistClick = { artist ->
|
|
||||||
navigationCommands.navigateTo(
|
|
||||||
Routes.Artist(
|
|
||||||
artist.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onLoadMore = { viewModel.loadMoreRelatedArtists() },
|
onBulkPlayNext = { tracks ->
|
||||||
)
|
viewModel.playTracksNext(tracks)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
||||||
@ -394,8 +240,17 @@ 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(
|
||||||
@ -421,8 +276,18 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -432,28 +297,29 @@ 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,
|
contentDescription = artist?.name ?: "Artist",
|
||||||
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,
|
contentDescription = artist?.name ?: "Artist",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(size * 0.4f),
|
modifier = Modifier.size(size * 0.4f),
|
||||||
)
|
)
|
||||||
@ -463,7 +329,7 @@ private fun ArtistAvatar(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistMeta(
|
private fun ArtistMeta(
|
||||||
artist: MetadataArtist.Detailed,
|
artist: MetadataArtist.Detailed?,
|
||||||
isCompact: Boolean,
|
isCompact: Boolean,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@ -471,36 +337,38 @@ 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),
|
||||||
) {
|
) {
|
||||||
Text(
|
TextWithShimmer(
|
||||||
text = artist.name,
|
text = artist?.name ?: "Loading artist...",
|
||||||
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) TextAlign.Center else TextAlign.Start,
|
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
artist?.let {
|
||||||
|
TextWithShimmer(
|
||||||
text = buildString {
|
text = buildString {
|
||||||
append(formatFollowers(artist.followersCount))
|
append(formatFollowers(it.followersCount))
|
||||||
if (artist.genres.isNotEmpty()) {
|
if (it.genres.isNotEmpty()) {
|
||||||
append(" • ")
|
append(" • ")
|
||||||
append(artist.genres.joinToString(", "))
|
append(it.genres.joinToString(", "))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
artist.biography?.takeIf { it.isNotBlank() }?.let {
|
artist?.biography?.takeIf { it.isNotBlank() }?.let {
|
||||||
Text(
|
TextWithShimmer(
|
||||||
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) TextAlign.Center else TextAlign.Start,
|
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -509,6 +377,7 @@ private fun ArtistMeta(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistHeaderActions(
|
private fun ArtistHeaderActions(
|
||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
|
isSaving: Boolean,
|
||||||
onFollowClick: () -> Unit,
|
onFollowClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@ -516,12 +385,91 @@ private fun ArtistHeaderActions(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (isSaved) {
|
if (isSaved) {
|
||||||
FilledTonalButton(onClick = onFollowClick) {
|
FilledTonalButton(onClick = onFollowClick, enabled = !isSaving) {
|
||||||
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
TextWithShimmer("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(onClick = onFollowClick) {
|
Button(onClick = onFollowClick, enabled = !isSaving) {
|
||||||
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
TextWithShimmer("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistAlbumsSection(
|
||||||
|
albums: List<MetadataAlbum.Detailed>,
|
||||||
|
isLoading: Boolean,
|
||||||
|
error: String?,
|
||||||
|
hasMore: Boolean,
|
||||||
|
onViewAll: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
TextWithShimmer(
|
||||||
|
text = "Albums",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onViewAll,
|
||||||
|
enabled = hasMore && !isLoading,
|
||||||
|
) {
|
||||||
|
TextWithShimmer("View all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && albums.isEmpty()) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
items(4) {
|
||||||
|
SkeletonTree(true) {
|
||||||
|
PlayableCard(
|
||||||
|
title = "Album Title",
|
||||||
|
subtitle = "Artist Name",
|
||||||
|
imageURL = "https://placehold.co/600x400",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (error != null && albums.isEmpty()) {
|
||||||
|
TextWithShimmer(
|
||||||
|
text = error,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
} else if (albums.isEmpty()) {
|
||||||
|
TextWithShimmer(
|
||||||
|
text = "No albums found",
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val rowState = rememberLazyListState()
|
||||||
|
LazyRow(
|
||||||
|
state = rowState,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
items(albums, key = { it.id }) { album ->
|
||||||
|
AlbumCard(album = album)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -539,7 +487,7 @@ private fun TopTracksHeader(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
TextWithShimmer(
|
||||||
text = "Top Tracks",
|
text = "Top Tracks",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
@ -562,130 +510,6 @@ private fun TopTracksHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AlbumsSection(
|
|
||||||
albums: List<MetadataAlbum.Detailed>,
|
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
|
||||||
onLoadMore: () -> Unit,
|
|
||||||
) {
|
|
||||||
val rowState = rememberLazyListState()
|
|
||||||
|
|
||||||
LaunchedEffect(rowState) {
|
|
||||||
snapshotFlow { rowState.layoutInfo }
|
|
||||||
.map { layoutInfo ->
|
|
||||||
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
|
||||||
val totalItems = layoutInfo.totalItemsCount
|
|
||||||
Pair(lastVisibleIndex, totalItems)
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { (lastVisibleIndex, totalItems) ->
|
|
||||||
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) {
|
|
||||||
onLoadMore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionHeader(title = "Albums")
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
LazyRow(
|
|
||||||
state = rowState,
|
|
||||||
modifier = Modifier.dragScrollable(rowState),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
) {
|
|
||||||
items(albums, key = { it.id }) { album ->
|
|
||||||
AlbumCard(album = album)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun RelatedArtistsSection(
|
|
||||||
artists: List<MetadataArtist.Basic>,
|
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
|
||||||
onLoadMore: () -> Unit,
|
|
||||||
) {
|
|
||||||
val rowState = rememberLazyListState()
|
|
||||||
|
|
||||||
LaunchedEffect(rowState) {
|
|
||||||
snapshotFlow { rowState.layoutInfo }
|
|
||||||
.map { layoutInfo ->
|
|
||||||
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
|
||||||
val totalItems = layoutInfo.totalItemsCount
|
|
||||||
Pair(lastVisibleIndex, totalItems)
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { (lastVisibleIndex, totalItems) ->
|
|
||||||
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) {
|
|
||||||
onLoadMore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionHeader(title = "Related Artists")
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
LazyRow(
|
|
||||||
state = rowState,
|
|
||||||
modifier = Modifier.dragScrollable(rowState),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
) {
|
|
||||||
items(artists, key = { it.id }) { artist ->
|
|
||||||
ArtistCard(artist = artist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FeaturedPlaylistsSection(
|
|
||||||
playlists: List<MetadataPlaylist>,
|
|
||||||
onPlaylistClick: (MetadataPlaylist) -> Unit,
|
|
||||||
onLoadMore: () -> Unit,
|
|
||||||
) {
|
|
||||||
val rowState = rememberLazyListState()
|
|
||||||
|
|
||||||
LaunchedEffect(rowState) {
|
|
||||||
snapshotFlow { rowState.layoutInfo }
|
|
||||||
.map { layoutInfo ->
|
|
||||||
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
|
|
||||||
val totalItems = layoutInfo.totalItemsCount
|
|
||||||
Pair(lastVisibleIndex, totalItems)
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { (lastVisibleIndex, totalItems) ->
|
|
||||||
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) {
|
|
||||||
onLoadMore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SectionHeader(title = "Featured Playlists")
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
LazyRow(
|
|
||||||
state = rowState,
|
|
||||||
modifier = Modifier.dragScrollable(rowState),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
) {
|
|
||||||
items(playlists, key = { it.id }) { playlist ->
|
|
||||||
PlaylistCard(playlist = playlist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionHeader(title: String) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatFollowers(count: Int?): String {
|
private fun formatFollowers(count: Int?): String {
|
||||||
if (count == null) return "Followers unavailable"
|
if (count == null) return "Followers unavailable"
|
||||||
return when {
|
return when {
|
||||||
@ -705,3 +529,4 @@ private fun formatAbbreviatedCount(count: Int, divisor: Int): String {
|
|||||||
rounded.toString()
|
rounded.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,77 +22,106 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.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
|
|
||||||
|
|
||||||
sealed interface ArtistScreenState {
|
data class ArtistInfoState(
|
||||||
data object Loading : ArtistScreenState
|
val artist: MetadataArtist.Detailed? = null,
|
||||||
|
val isSaved: Boolean = false,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
data class Loaded(
|
data class ArtistTopTracksState(
|
||||||
val artist: MetadataArtist.Detailed,
|
val items: List<MetadataTrack> = emptyList(),
|
||||||
val isArtistSaved: Boolean,
|
val isLoading: Boolean = false,
|
||||||
val topTracks: List<MetadataTrack>,
|
val error: String? = null,
|
||||||
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 Error(val message: String) : ArtistScreenState
|
data class ArtistAlbumsState(
|
||||||
}
|
val items: List<MetadataAlbum.Detailed> = emptyList(),
|
||||||
|
val nextPagination: PaginationStrategy? = null,
|
||||||
|
val hasNextPage: Boolean = true,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalStdlibApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class ArtistViewModel(
|
class ArtistViewModel(
|
||||||
private val artistId: String,
|
private val artistId: String,
|
||||||
private val repository: ArtistRepository,
|
private val pluginManager: PluginManager,
|
||||||
private val libraryRepository: LibraryRepository,
|
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel() {
|
||||||
private val logger by injectLogger<ArtistViewModel>()
|
companion object {
|
||||||
|
private const val ALBUMS_PAGE_SIZE = 20
|
||||||
|
}
|
||||||
|
|
||||||
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
|
private val _artistInfo = MutableStateFlow(ArtistInfoState())
|
||||||
val state: StateFlow<ArtistScreenState> = _state.asStateFlow()
|
val artistInfo: StateFlow<ArtistInfoState> = _artistInfo.asStateFlow()
|
||||||
|
|
||||||
|
private val _topTracks = MutableStateFlow(ArtistTopTracksState())
|
||||||
|
val topTracks: StateFlow<ArtistTopTracksState> = _topTracks.asStateFlow()
|
||||||
|
|
||||||
|
private val _albums = MutableStateFlow(ArtistAlbumsState())
|
||||||
|
val albums: StateFlow<ArtistAlbumsState> = _albums.asStateFlow()
|
||||||
val savedArtistIds
|
val savedArtistIds
|
||||||
get() = libraryRepository.savedArtistIdsFlow
|
get() = libraryRepository.savedArtistIdsFlow
|
||||||
|
|
||||||
val savedTrackIds
|
|
||||||
get() = savedTracksRepository.savedTracksIdsFlow
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
libraryRepository.savedArtistIdsFlow.collect {
|
pluginManager.selectedMetadataPlugin
|
||||||
val currentState = _state.value
|
.filterNotNull()
|
||||||
if (currentState is ArtistScreenState.Loaded) {
|
.distinctUntilChanged()
|
||||||
_state.value = currentState.copy(isArtistSaved = it.contains(artistId))
|
.flatMapLatest { it.loggedInFlow }
|
||||||
|
.collect {
|
||||||
|
_artistInfo.value = ArtistInfoState()
|
||||||
|
_topTracks.value = ArtistTopTracksState()
|
||||||
|
_albums.value = ArtistAlbumsState()
|
||||||
|
|
||||||
|
loadArtistInfo()
|
||||||
|
loadTopTracks()
|
||||||
|
loadAlbumsPage(reset = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadOverview()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
fun refreshArtist() {
|
||||||
repository.invalidateCaches()
|
viewModelScope.launch {
|
||||||
loadOverview()
|
loadArtistInfo()
|
||||||
|
loadTopTracks()
|
||||||
|
loadAlbumsPage(reset = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadNextAlbumsPage() {
|
||||||
|
val current = _albums.value
|
||||||
|
if (current.isLoading || !current.hasNextPage || current.nextPagination == null) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
loadAlbumsPage(reset = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSavedArtist() {
|
fun toggleSavedArtist() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
|
val isLiked =
|
||||||
|
libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
|
||||||
if (isLiked) {
|
if (isLiked) {
|
||||||
libraryRepository.removeSavedArtists(listOf(artistId))
|
libraryRepository.removeSavedArtists(listOf(artistId))
|
||||||
} else {
|
} else {
|
||||||
@ -101,73 +130,11 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMoreAlbums() {
|
|
||||||
val currentState = _state.value
|
|
||||||
if (currentState !is ArtistScreenState.Loaded) return
|
|
||||||
val pagination = currentState.albumsNextPagination ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching {
|
|
||||||
repository.albums(artistId, pagination)
|
|
||||||
}.onSuccess { result ->
|
|
||||||
if (result != null) {
|
|
||||||
_state.value = currentState.copy(
|
|
||||||
albums = currentState.albums + result.items,
|
|
||||||
albumsNextPagination = result.nextPagination,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to load more albums" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadMoreRelatedArtists() {
|
|
||||||
val currentState = _state.value
|
|
||||||
if (currentState !is ArtistScreenState.Loaded) return
|
|
||||||
val pagination = currentState.relatedArtistsNextPagination ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching {
|
|
||||||
repository.relatedArtists(artistId, pagination)
|
|
||||||
}.onSuccess { result ->
|
|
||||||
if (result != null) {
|
|
||||||
_state.value = currentState.copy(
|
|
||||||
relatedArtists = currentState.relatedArtists + result.items,
|
|
||||||
relatedArtistsNextPagination = result.nextPagination,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to load more related artists" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadMoreFeaturedPlaylists() {
|
|
||||||
val currentState = _state.value
|
|
||||||
if (currentState !is ArtistScreenState.Loaded) return
|
|
||||||
val pagination = currentState.featuredPlaylistsNextPagination ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
runCatching {
|
|
||||||
repository.featuredPlaylists(artistId, pagination)
|
|
||||||
}.onSuccess { result ->
|
|
||||||
if (result != null) {
|
|
||||||
_state.value = currentState.copy(
|
|
||||||
featuredPlaylists = currentState.featuredPlaylists + result.items,
|
|
||||||
featuredPlaylistsNextPagination = result.nextPagination,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
logger.e(e) { "Failed to load more featured playlists" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTopTracksToQueue() {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,6 +143,7 @@ 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,
|
||||||
@ -212,17 +180,128 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
private suspend fun loadArtistInfo() {
|
||||||
viewModelScope.launch {
|
val plugin = pluginManager.selectedMetadataPlugin.value
|
||||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
if (plugin == null) {
|
||||||
|
_artistInfo.value = ArtistInfoState(
|
||||||
|
artist = null,
|
||||||
|
isSaved = false,
|
||||||
|
isLoading = false,
|
||||||
|
isSaving = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_artistInfo.value = _artistInfo.value.copy(isLoading = true, error = null)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
pluginManager.asyncTask {
|
||||||
|
plugin.use {
|
||||||
|
val artist = metadataArtistAPI.getArtist(artistId)
|
||||||
|
val isSaved =
|
||||||
|
metadataArtistAPI.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
|
||||||
|
artist to isSaved
|
||||||
|
}
|
||||||
|
}.await()
|
||||||
|
}.onSuccess { (artist, isSaved) ->
|
||||||
|
_artistInfo.value = _artistInfo.value.copy(
|
||||||
|
artist = artist,
|
||||||
|
isSaved = isSaved,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
libraryRepository.isSavedArtists(listOf(artistId))
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
_artistInfo.value = _artistInfo.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = throwable.message ?: "Failed to load artist",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
private suspend fun loadTopTracks() {
|
||||||
viewModelScope.launch {
|
val plugin = pluginManager.selectedMetadataPlugin.value
|
||||||
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
|
||||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
if (plugin == null) {
|
||||||
|
_topTracks.value =
|
||||||
|
ArtistTopTracksState(items = emptyList(), isLoading = false, error = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_topTracks.value = _topTracks.value.copy(isLoading = true, error = null)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
pluginManager.asyncTask {
|
||||||
|
plugin.use {
|
||||||
|
metadataArtistAPI.getArtistTop10Tracks(artistId)
|
||||||
|
}
|
||||||
|
}.await()
|
||||||
|
}.onSuccess { tracks ->
|
||||||
|
savedTracksRepository.isSavedTracks(tracks.map { item -> item.id })
|
||||||
|
_topTracks.value = ArtistTopTracksState(
|
||||||
|
items = tracks,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
_topTracks.value = _topTracks.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = throwable.message ?: "Failed to load artist top tracks",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadAlbumsPage(reset: Boolean) {
|
||||||
|
val plugin = pluginManager.selectedMetadataPlugin.value
|
||||||
|
|
||||||
|
if (plugin == null) {
|
||||||
|
_albums.value = ArtistAlbumsState(
|
||||||
|
items = emptyList(),
|
||||||
|
nextPagination = null,
|
||||||
|
hasNextPage = false,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = _albums.value
|
||||||
|
val offset = if (reset) 0 else current.nextPagination ?: return
|
||||||
|
|
||||||
|
_albums.value = if (reset) {
|
||||||
|
current.copy(items = emptyList(), isLoading = true, error = null)
|
||||||
|
} else {
|
||||||
|
current.copy(isLoading = true, error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
pluginManager.asyncTask {
|
||||||
|
plugin.use {
|
||||||
|
metadataArtistAPI.getArtistAlbums(artistId)
|
||||||
|
}
|
||||||
|
}.await()
|
||||||
|
}.onSuccess { page ->
|
||||||
|
val mergedItems = if (reset) page.items else _albums.value.items + page.items
|
||||||
|
_albums.value = ArtistAlbumsState(
|
||||||
|
items = mergedItems,
|
||||||
|
nextPagination = page.nextPagination,
|
||||||
|
hasNextPage = page.nextPagination != null,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
_albums.value = _albums.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = throwable.message ?: "Failed to load artist albums",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTopTrackEntries(): List<QueueEntry> {
|
||||||
|
return _topTracks.value.items.map { track ->
|
||||||
|
QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,47 +352,28 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadOverview() {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
_state.value = ArtistScreenState.Loading
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
repository.artistOverview(artistId)
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
}.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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTopTrackEntries(): List<QueueEntry> {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
val currentState = _state.value
|
viewModelScope.launch {
|
||||||
if (currentState !is ArtistScreenState.Loaded) return emptyList()
|
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||||
return currentState.topTracks.map { track ->
|
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||||
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,11 +49,8 @@ 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
|
||||||
@ -83,15 +80,6 @@ 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 ->
|
||||||
@ -131,7 +119,6 @@ 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(
|
||||||
@ -145,7 +132,6 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
state = state,
|
state = state,
|
||||||
onRetry = { viewModel.refresh() },
|
onRetry = { viewModel.refresh() },
|
||||||
onGenreSelected = { viewModel.selectGenre(it) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,7 +150,6 @@ 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) {
|
||||||
@ -231,55 +216,13 @@ private fun HomeContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.genres.isNotEmpty()) {
|
items(state.browseSections) { section ->
|
||||||
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 {
|
||||||
@ -364,26 +307,3 @@ private fun HomeSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun GenreTabs(
|
|
||||||
genres: List<MetadataBrowseGenre>,
|
|
||||||
selectedGenreId: String?,
|
|
||||||
onGenreSelected: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
val rowState = rememberLazyListState()
|
|
||||||
|
|
||||||
LazyRow(
|
|
||||||
state = rowState,
|
|
||||||
modifier = Modifier.dragScrollable(rowState),
|
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
items(genres) { genre ->
|
|
||||||
ChipTab(
|
|
||||||
text = genre.name,
|
|
||||||
selected = selectedGenreId == genre.id,
|
|
||||||
onClick = { onGenreSelected(genre.id) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.home
|
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
|
||||||
@ -46,10 +45,8 @@ 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<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseSection>>()
|
Cache.Builder<PaginationStrategy, PaginationResult<MetadataBrowseSection>>().build()
|
||||||
.build()
|
|
||||||
private val sublistItemCache =
|
private val sublistItemCache =
|
||||||
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>()
|
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>()
|
||||||
.build()
|
.build()
|
||||||
@ -68,7 +65,6 @@ class HomeScreenRepository(
|
|||||||
|
|
||||||
fun invalidateCaches() {
|
fun invalidateCaches() {
|
||||||
featuredItemCache.invalidateAll()
|
featuredItemCache.invalidateAll()
|
||||||
genresCache.invalidateAll()
|
|
||||||
browseItemCache.invalidateAll()
|
browseItemCache.invalidateAll()
|
||||||
sublistItemCache.invalidateAll()
|
sublistItemCache.invalidateAll()
|
||||||
}
|
}
|
||||||
@ -83,43 +79,32 @@ class HomeScreenRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun genres() = plugin?.let { plugin ->
|
suspend fun list(paginationStrategy: PaginationStrategy? = null) =
|
||||||
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 = genreId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
|
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
|
||||||
) {
|
) {
|
||||||
pluginManager.withScope {
|
pluginManager.withScope {
|
||||||
plugin.use {
|
plugin.use {
|
||||||
metadataBrowseAPI.list(genreId, paginationStrategy)
|
metadataBrowseAPI.list(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(
|
||||||
"$genreId/$parentId" to (paginationStrategy ?: PaginationStrategy.Offset(
|
parentId to (paginationStrategy ?: PaginationStrategy.Offset(
|
||||||
0,
|
0,
|
||||||
20
|
20
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
pluginManager.withScope {
|
pluginManager.withScope {
|
||||||
plugin.use {
|
plugin.use {
|
||||||
metadataBrowseAPI.sublist(genreId, parentId, paginationStrategy)
|
metadataBrowseAPI.sublist(parentId, paginationStrategy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@ package dev.krtirtho.spotube.modules.home
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.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
|
||||||
@ -28,11 +27,19 @@ 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
|
||||||
|
|
||||||
@ -41,33 +48,29 @@ sealed interface HomeScreenState {
|
|||||||
|
|
||||||
sealed interface Data : HomeScreenState {
|
sealed interface Data : HomeScreenState {
|
||||||
val featuredItems: List<MetadataBrowseItem>
|
val featuredItems: List<MetadataBrowseItem>
|
||||||
val genres: List<MetadataBrowseGenre>
|
val browseSections: List<MetadataBrowseSection>
|
||||||
val selectedGenreId: String?
|
val paginationStrategy: PaginationStrategy?
|
||||||
val browseSections: Map<String, List<MetadataBrowseSection>?>
|
val totalItems: Int
|
||||||
val paginationStrategies: Map<String, PaginationStrategy?>
|
|
||||||
|
|
||||||
data class Loaded(
|
data class Loaded(
|
||||||
override val featuredItems: List<MetadataBrowseItem>,
|
override val featuredItems: List<MetadataBrowseItem>,
|
||||||
override val genres: List<MetadataBrowseGenre>,
|
override val browseSections: List<MetadataBrowseSection>,
|
||||||
override val selectedGenreId: String?,
|
override val paginationStrategy: PaginationStrategy?,
|
||||||
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
|
override val totalItems: Int,
|
||||||
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,
|
||||||
paginationStrategies = paginationStrategies,
|
paginationStrategy = paginationStrategy,
|
||||||
|
totalItems = totalItems,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoadingMore(
|
data class LoadingMore(
|
||||||
override val featuredItems: List<MetadataBrowseItem>,
|
override val featuredItems: List<MetadataBrowseItem>,
|
||||||
override val genres: List<MetadataBrowseGenre>,
|
override val browseSections: List<MetadataBrowseSection>,
|
||||||
override val selectedGenreId: String?,
|
override val paginationStrategy: PaginationStrategy?,
|
||||||
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
|
override val totalItems: Int,
|
||||||
override val paginationStrategies: Map<String, PaginationStrategy?>,
|
|
||||||
) : Data
|
) : Data
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,67 +99,30 @@ class HomeScreenViewModel(
|
|||||||
|
|
||||||
private suspend fun loadInitialData() = runCatching {
|
private suspend fun loadInitialData() = runCatching {
|
||||||
state.value = HomeScreenState.Loading
|
state.value = HomeScreenState.Loading
|
||||||
val featuredItems = repository.featuredItems() ?: emptyList()
|
val featuredItems = repository.featuredItems()
|
||||||
val genres = repository.genres() ?: emptyList()
|
val browseSections = repository.list()
|
||||||
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,
|
featuredItems = featuredItems ?: emptyList(),
|
||||||
genres = genres,
|
browseSections = browseSections?.items ?: emptyList(),
|
||||||
selectedGenreId = firstGenreId,
|
paginationStrategy = browseSections?.nextPagination,
|
||||||
browseSections = browseSections,
|
totalItems = browseSections?.items?.size ?: 0,
|
||||||
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) {
|
if (currentState is HomeScreenState.Data.Loaded && currentState.paginationStrategy != null) {
|
||||||
val genreId = currentState.selectedGenreId ?: return@runCatching
|
|
||||||
val pagination = currentState.paginationStrategies[genreId] ?: return@runCatching
|
|
||||||
state.value = currentState.toLoadingMore()
|
state.value = currentState.toLoadingMore()
|
||||||
val result = repository.list(genreId, pagination)
|
val browseSectionsResult = repository.list(currentState.paginationStrategy)
|
||||||
val existingSections = currentState.browseSections[genreId] ?: emptyList()
|
|
||||||
state.value = HomeScreenState.Data.Loaded(
|
state.value = HomeScreenState.Data.Loaded(
|
||||||
featuredItems = currentState.featuredItems,
|
featuredItems = currentState.featuredItems,
|
||||||
genres = currentState.genres,
|
browseSections = currentState.browseSections + (browseSectionsResult?.items
|
||||||
selectedGenreId = genreId,
|
?: emptyList()),
|
||||||
browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))),
|
paginationStrategy = browseSectionsResult?.nextPagination,
|
||||||
paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination),
|
totalItems = currentState.totalItems,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
|
|||||||
@ -34,7 +34,6 @@ 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
|
||||||
@ -148,13 +147,9 @@ class PluginManager(
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val ziplineServices = state
|
val ziplineServices = state
|
||||||
.filterIsInstance<PluginManagerStates.Data>()
|
.filterIsInstance<PluginManagerStates.Data>()
|
||||||
.map { it.selectedPlugins to it.generation }
|
.map { it.selectedPlugins }
|
||||||
// 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, gen) ->
|
.flatMapLatest { plugins ->
|
||||||
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()
|
||||||
@ -171,7 +166,6 @@ 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")}",
|
||||||
@ -186,17 +180,13 @@ 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 {
|
||||||
withContext(NonCancellable) {
|
// Stop services that are no longer selected
|
||||||
logger.d { "ziplineServices: finally block stopping ${ziplineServicesByPluginID.size} services" }
|
val selectedPluginIDs = plugins.values.map { it.id }.toSet()
|
||||||
ziplineServicesByPluginID.forEach { (_, service) ->
|
ziplineServicesByPluginID.forEach { (pluginID, service) ->
|
||||||
try {
|
if (!selectedPluginIDs.contains(pluginID)) {
|
||||||
service.stop()
|
service.stop()
|
||||||
} catch (_: Exception) {
|
|
||||||
logger.e { "ziplineServices: error stopping service" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.d { "ziplineServices: all services stopped" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -505,12 +495,7 @@ class PluginManager(
|
|||||||
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
|
||||||
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
|
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
|
||||||
}
|
}
|
||||||
val newState = PluginManagerStates.Data(
|
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins)
|
||||||
plugins = updatedPlugins,
|
|
||||||
selectedPlugins = updatedSelectedPlugins,
|
|
||||||
generation = currentState.generation + 1
|
|
||||||
)
|
|
||||||
logger.d { "addPlugin: bumped generation to ${newState.generation} for plugin ${plugin.id}" }
|
|
||||||
updatePluginsState(newState)
|
updatePluginsState(newState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -525,11 +510,7 @@ 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(
|
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins)
|
||||||
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,12 +55,7 @@ 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,10 +19,8 @@ package dev.krtirtho.js_plugin_example.plugin_apis.metadata
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.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 {
|
||||||
@ -31,28 +29,10 @@ 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,7 +17,6 @@
|
|||||||
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
|
||||||
@ -29,22 +28,11 @@ class RealMetadataBrowseAPI : MetadataBrowseAPI {
|
|||||||
return FakeMetadataStore.getFeaturedItems()
|
return FakeMetadataStore.getFeaturedItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun genres(): List<MetadataBrowseGenre> {
|
override suspend fun list(pagination: PaginationStrategy?): PaginationResult<MetadataBrowseSection> {
|
||||||
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,26 +20,13 @@ import app.cash.zipline.ZiplineService
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.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,11 +16,7 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@ -52,12 +48,3 @@ 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,7 +24,6 @@ const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI"
|
|||||||
|
|
||||||
interface MetadataBrowseAPI: ZiplineService {
|
interface MetadataBrowseAPI: ZiplineService {
|
||||||
suspend fun featured(): List<MetadataBrowseItem>
|
suspend fun featured(): List<MetadataBrowseItem>
|
||||||
suspend fun genres(): List<MetadataBrowseGenre>
|
suspend fun list(pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
|
||||||
suspend fun list(genreId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
|
suspend fun sublist(sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
|
||||||
suspend fun sublist(genreId: String, sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
|
|
||||||
}
|
}
|
||||||
@ -54,9 +54,3 @@ 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