From f5ed2453ca53ee88f591ae1f100a67624df41fb8 Mon Sep 17 00:00:00 2001 From: Talo Halton Date: Fri, 18 Aug 2023 21:15:22 +0100 Subject: [PATCH] Fix #114, add artist refresh and loading indicator Centralise browse endpoint page type matching to MediaItemType.fromBrowseEndpointType (fixes #114) Add pull refresh to artist page #88 Fix albums not loading Add loading indicator and error display to artist page --- .../toasterofbread/spmp/api/LoadMediaitem.kt | 5 +- .../spmp/api/model/BrowseEndpoint.kt | 6 - .../model/MusicResponsiveListItemRenderer.kt | 23 +- .../spmp/api/model/MusicTwoRowItemRenderer.kt | 8 +- .../spmp/api/radio/YoutubeiNextResponse.kt | 6 +- .../spmp/model/mediaitem/MediaItem.kt | 13 +- .../spmp/model/mediaitem/Playlist.kt | 3 +- .../spmp/model/mediaitem/db/QueryProperty.kt | 10 +- .../model/mediaitem/enums/MediaItemType.kt | 42 +-- .../model/mediaitem/enums/PlaylistType.kt | 2 +- .../model/mediaitem/loader/MediaItemLoader.kt | 11 +- .../spmp/ui/layout/YoutubeMusicLogin.kt | 3 +- .../spmp/ui/layout/artistpage/ArtistPage.kt | 328 ++++++++++-------- .../ui/layout/playlistpage/PlaylistFooter.kt | 68 ++-- .../ui/layout/playlistpage/PlaylistItems.kt | 12 +- .../ui/layout/playlistpage/PlaylistPage.kt | 27 +- 16 files changed, 300 insertions(+), 267 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt index cef1e758a..a70fc64b3 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt @@ -19,6 +19,7 @@ import com.toasterofbread.spmp.model.mediaitem.PlaylistData import com.toasterofbread.spmp.model.mediaitem.Song import com.toasterofbread.spmp.model.mediaitem.SongData import com.toasterofbread.spmp.model.mediaitem.artist.ArtistLayout +import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType import com.toasterofbread.spmp.model.mediaitem.enums.PlaylistType import com.toasterofbread.spmp.model.mediaitem.enums.SongType import com.toasterofbread.spmp.resources.uilocalisation.LocalisedYoutubeString @@ -80,7 +81,7 @@ suspend fun loadMediaItemData( "isAudioOnly" to true, "videoId" to item_id, ) - else if (item is Playlist && !item.id.startsWith("VL")) + else if (item is Playlist && !item.id.startsWith("VL") && !item.id.startsWith("MPREb_")) mapOf( "browseId" to "VL$item_id" ) @@ -249,7 +250,7 @@ suspend fun processDefaultResponse(item: MediaItemData, response: Response, hl: header_renderer.subtitle?.runs?.also { subtitle -> if (item is MediaItem.DataWithArtist) { val artist_run = subtitle.firstOrNull { - it.navigationEndpoint?.browseEndpoint?.getPageType() == "MUSIC_PAGE_TYPE_USER_CHANNEL" + it.navigationEndpoint?.browseEndpoint?.getMediaItemType() == MediaItemType.ARTIST } if (artist_run != null) { item.artist = ArtistData(artist_run.navigationEndpoint!!.browseEndpoint!!.browseId).apply { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/BrowseEndpoint.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/BrowseEndpoint.kt index 69e7cf655..95a86a6eb 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/BrowseEndpoint.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/BrowseEndpoint.kt @@ -1,12 +1,6 @@ package com.toasterofbread.spmp.api.model -import com.toasterofbread.spmp.model.mediaitem.AccountPlaylistRef -import com.toasterofbread.spmp.model.mediaitem.ArtistData -import com.toasterofbread.spmp.model.mediaitem.MediaItem import com.toasterofbread.spmp.model.mediaitem.MediaItemData -import com.toasterofbread.spmp.model.mediaitem.PlaylistData -import com.toasterofbread.spmp.model.mediaitem.PlaylistRef -import com.toasterofbread.spmp.model.mediaitem.SongData import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType import com.toasterofbread.spmp.ui.component.mediaitemlayout.MediaItemLayout diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicResponsiveListItemRenderer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicResponsiveListItemRenderer.kt index 9a07f7f54..02da448c0 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicResponsiveListItemRenderer.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicResponsiveListItemRenderer.kt @@ -30,17 +30,22 @@ data class MusicResponsiveListItemRenderer( if (video_id == null) { val page_type = navigationEndpoint?.browseEndpoint?.getPageType() - when (page_type) { - "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_PLAYLIST" -> { + when ( + page_type?.let { type -> + MediaItemType.fromBrowseEndpointType(type) + } + ) { + MediaItemType.PLAYLIST_ACC -> { video_is_main = false playlist = PlaylistData(navigationEndpoint!!.browseEndpoint!!.browseId).apply { - playlist_type = PlaylistType.fromTypeString(page_type) + playlist_type = PlaylistType.fromBrowseEndpointType(page_type) } } - "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> { + MediaItemType.ARTIST -> { video_is_main = false artist = ArtistData(navigationEndpoint!!.browseEndpoint!!.browseId) } + else -> {} } } @@ -68,13 +73,9 @@ data class MusicResponsiveListItemRenderer( } val browse_endpoint = run.navigationEndpoint.browseEndpoint - when (browse_endpoint?.getPageType()) { - "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> { - if (artist == null) { - artist = ArtistData(browse_endpoint.browseId) - artist.title = run.text - } - } + if (artist == null && browse_endpoint?.getMediaItemType() == MediaItemType.ARTIST) { + artist = ArtistData(browse_endpoint.browseId) + artist.title = run.text } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicTwoRowItemRenderer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicTwoRowItemRenderer.kt index 7fc8e361a..e6cf96b68 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicTwoRowItemRenderer.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicTwoRowItemRenderer.kt @@ -73,20 +73,20 @@ class MusicTwoRowItemRenderer( val browse_id = navigationEndpoint.browseEndpoint!!.browseId val page_type = navigationEndpoint.browseEndpoint.getPageType()!! - item = when (page_type) { - "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_PLAYLIST", "MUSIC_PAGE_TYPE_AUDIOBOOK", "MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE" -> { + item = when (MediaItemType.fromBrowseEndpointType(page_type)) { + MediaItemType.PLAYLIST_ACC -> { if (Playlist.formatYoutubeId(browse_id).startsWith("RDAT") && !Settings.get(Settings.KEY_FEED_SHOW_RADIOS)) { return null } PlaylistData(browse_id).also { data -> - data.playlist_type = PlaylistType.fromTypeString(page_type) + data.playlist_type = PlaylistType.fromBrowseEndpointType(page_type) data.artist = getArtist(data) // is_editable = menu?.menuRenderer?.items // ?.any { it.menuNavigationItemRenderer?.icon?.iconType == "DELETE" } == true } } - "MUSIC_PAGE_TYPE_ARTIST" -> ArtistData(browse_id) + MediaItemType.ARTIST -> ArtistData(browse_id) else -> throw NotImplementedError("$page_type ($browse_id)") } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/radio/YoutubeiNextResponse.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/radio/YoutubeiNextResponse.kt index 8fdc29efc..46e3ae02d 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/radio/YoutubeiNextResponse.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/radio/YoutubeiNextResponse.kt @@ -12,6 +12,7 @@ import com.toasterofbread.spmp.model.mediaitem.ArtistData import com.toasterofbread.spmp.model.mediaitem.PlaylistData import com.toasterofbread.spmp.model.mediaitem.Song import com.toasterofbread.spmp.model.mediaitem.db.loadMediaItemValue +import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType data class YoutubeiNextResponse( val contents: Contents @@ -69,7 +70,10 @@ data class YoutubeiNextResponse( suspend fun getArtist(host_item: Song, db: Database): Result { // Get artist ID directly for (run in longBylineText.runs!! + title.runs!!) { - if (run.browse_endpoint_type != "MUSIC_PAGE_TYPE_ARTIST" && run.browse_endpoint_type != "MUSIC_PAGE_TYPE_USER_CHANNEL") { + val page_type = run.browse_endpoint_type?.let { type -> + MediaItemType.fromBrowseEndpointType(type) + } + if (page_type != MediaItemType.ARTIST) { continue } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt index 471caed4c..f011c028e 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt @@ -200,17 +200,8 @@ sealed class MediaItemData: MediaItem { get() = thumbnail_provider.asMediaItemProperty(super.ThumbnailProvider) { thumbnail_provider = it } companion object { - fun fromBrowseEndpointType(page_type: String, id: String): MediaItemData { - return when (page_type) { - "MUSIC_PAGE_TYPE_PLAYLIST", "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_AUDIOBOOK" -> - PlaylistData(id).apply { playlist_type = PlaylistTypeEnum.fromTypeString(page_type) } - "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> - ArtistData(id) - "MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE" -> - SongData(id) - else -> throw NotImplementedError("page_type=$page_type, id=$page_type") - } - } + fun fromBrowseEndpointType(page_type: String, id: String): MediaItemData = + MediaItemType.fromBrowseEndpointType(page_type).referenceFromId(id).getEmptyData() } open fun saveToDatabase(db: Database, apply_to_item: MediaItem = this) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/Playlist.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/Playlist.kt index 5c27e3f63..a58265680 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/Playlist.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/Playlist.kt @@ -91,7 +91,8 @@ sealed interface Playlist: MediaItem.WithArtist { }, clearItems = { from_index -> playlistItemQueries.clearItems(id, from_index) - } + }, + prerequisite = Loaded ) val ItemCount: Property get() = property_rememberer.rememberSingleProperty( diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/db/QueryProperty.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/db/QueryProperty.kt index 509d2ebef..3fb7cd784 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/db/QueryProperty.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/db/QueryProperty.kt @@ -86,12 +86,10 @@ open class ListProperty( ) if (prerequisite != null) { - val pr: Boolean by prerequisite.observe(db) - return remember { - derivedStateOf { - if (!pr) null - else value_state.value - } + val prerequisite_state: State = prerequisite.observe(db) + return derivedStateOf { + if (!prerequisite_state.value) null + else value_state.value } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/MediaItemType.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/MediaItemType.kt index d9354225f..ff40c67b8 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/MediaItemType.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/MediaItemType.kt @@ -6,27 +6,12 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PlaylistPlay import androidx.compose.ui.graphics.vector.ImageVector import com.toasterofbread.spmp.model.mediaitem.AccountPlaylistRef -import com.toasterofbread.spmp.model.mediaitem.Artist import com.toasterofbread.spmp.model.mediaitem.ArtistRef import com.toasterofbread.spmp.model.mediaitem.LocalPlaylistRef import com.toasterofbread.spmp.model.mediaitem.MediaItem -import com.toasterofbread.spmp.model.mediaitem.PlaylistData -import com.toasterofbread.spmp.model.mediaitem.Song import com.toasterofbread.spmp.model.mediaitem.SongRef import com.toasterofbread.spmp.resources.getString -fun MediaItem.getType(): MediaItemType = - when(this) { - is Song -> MediaItemType.SONG - is Artist -> MediaItemType.ARTIST - is AccountPlaylistRef -> MediaItemType.PLAYLIST_ACC - is LocalPlaylistRef -> MediaItemType.PLAYLIST_LOC - is PlaylistData -> - if (isLocalPlaylist()) MediaItemType.PLAYLIST_LOC - else MediaItemType.PLAYLIST_ACC - else -> throw NotImplementedError(this::class.toString()) - } - enum class MediaItemType { SONG, ARTIST, PLAYLIST_ACC, PLAYLIST_LOC; @@ -65,11 +50,28 @@ enum class MediaItemType { } companion object { - fun fromBrowseEndpointType(page_type: String): MediaItemType? { - return when (page_type) { - "MUSIC_PAGE_TYPE_PLAYLIST", "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_AUDIOBOOK" -> PLAYLIST_ACC - "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> ARTIST - else -> null + fun fromBrowseEndpointType(page_type: String): MediaItemType { + // Remove "MUSIC_PAGE_TYPE_" prefix + val type_name: String = page_type.substring(16) + + if (type_name.startsWith("ARTIST")) { + return ARTIST + } + if (type_name.startsWith("PODCAST")) { + return PLAYLIST_ACC + } + + return when (type_name) { + "PLAYLIST", + "ALBUM", + "AUDIOBOOK", + "RADIO" -> + PLAYLIST_ACC + "USER_CHANNEL" -> + ARTIST + "NON_MUSIC_AUDIO_TRACK_PAGE" -> + SONG + else -> throw NotImplementedError(page_type) } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt index 25482c19a..9d9a0155c 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt @@ -6,7 +6,7 @@ enum class PlaylistType { LOCAL, PLAYLIST, ALBUM, AUDIOBOOK, PODCAST, RADIO; companion object { - fun fromTypeString(type: String): PlaylistType { + fun fromBrowseEndpointType(type: String): PlaylistType { return when (type) { "MUSIC_PAGE_TYPE_PLAYLIST" -> PLAYLIST "MUSIC_PAGE_TYPE_ALBUM" -> ALBUM diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/loader/MediaItemLoader.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/loader/MediaItemLoader.kt index eb154f913..1bc3abcab 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/loader/MediaItemLoader.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/loader/MediaItemLoader.kt @@ -3,6 +3,7 @@ package com.toasterofbread.spmp.model.mediaitem.loader import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,7 +70,7 @@ internal object MediaItemLoader: ListenerLoader() { } @Composable -fun MediaItem.loadDataOnChange(db: Database): State { +fun MediaItem.loadDataOnChange(db: Database, load: Boolean = true, onLoadFailed: ((Throwable?) -> Unit)? = null): State { val loading_state = remember(this) { mutableStateOf(false) } DisposableEffect(this) { @@ -86,6 +87,7 @@ fun MediaItem.loadDataOnChange(db: Database): State { } override fun onLoadFailed(key: String, error: Throwable) { if (key == id) { + onLoadFailed?.invoke(error) loading_state.value = false } } @@ -98,8 +100,11 @@ fun MediaItem.loadDataOnChange(db: Database): State { } } - LaunchedEffect(this) { - loadData(db) + LaunchedEffect(this, load) { + if (load) { + onLoadFailed?.invoke(null) + loadData(db) + } } return loading_state diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicLogin.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicLogin.kt index 4b0291f32..015a6a5d4 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicLogin.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/YoutubeMusicLogin.kt @@ -33,6 +33,7 @@ import com.toasterofbread.spmp.model.YoutubeMusicAuthInfo import com.toasterofbread.spmp.model.mediaitem.Artist import com.toasterofbread.spmp.model.mediaitem.ArtistData import com.toasterofbread.spmp.model.mediaitem.MediaItemThumbnailProvider +import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType import com.toasterofbread.spmp.platform.WebViewLogin import com.toasterofbread.spmp.platform.composable.PlatformAlertDialog import com.toasterofbread.spmp.platform.isWebViewLoginSupported @@ -241,7 +242,7 @@ data class YTAccountMenuResponse(val actions: List) { for (section in getSections()) { for (item in section.multiPageMenuSectionRenderer.items) { val browse_endpoint = item.compactLinkRenderer.navigationEndpoint?.browseEndpoint - if (browse_endpoint?.getPageType() == "MUSIC_PAGE_TYPE_USER_CHANNEL") { + if (browse_endpoint?.getMediaItemType() == MediaItemType.ARTIST) { return browse_endpoint.browseId } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt index 479379b1f..103912caa 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt @@ -40,9 +40,13 @@ import com.toasterofbread.spmp.model.mediaitem.Artist import com.toasterofbread.spmp.model.mediaitem.loader.MediaItemThumbnailLoader import com.toasterofbread.spmp.model.mediaitem.Playlist import com.toasterofbread.spmp.model.mediaitem.artist.ArtistLayout +import com.toasterofbread.spmp.model.mediaitem.loader.MediaItemLoader +import com.toasterofbread.spmp.model.mediaitem.loader.loadDataOnChange +import com.toasterofbread.spmp.platform.composable.SwipeRefresh import com.toasterofbread.spmp.resources.getString import com.toasterofbread.spmp.resources.uilocalisation.LocalisedYoutubeString import com.toasterofbread.spmp.resources.uilocalisation.YoutubeUILocalisation +import com.toasterofbread.spmp.ui.component.ErrorInfoDisplay import com.toasterofbread.spmp.ui.component.MusicTopBar import com.toasterofbread.spmp.ui.component.WaveBorder import com.toasterofbread.spmp.ui.component.longpressmenu.LongPressMenuData @@ -77,6 +81,8 @@ fun ArtistPage( } var load_error: Throwable? by remember { mutableStateOf(null) } + val loading by artist.loadDataOnChange(db, load = browse_params == null) { load_error = it } + var refreshed by remember { mutableStateOf(false) } val item_layouts: List? by artist.Layouts.observe(db) val single_layout: MediaItemLayout? = item_layouts?.singleOrNull()?.rememberMediaItemLayout(db) @@ -85,15 +91,6 @@ fun ArtistPage( val thumbnail_provider: MediaItemThumbnailProvider? by artist.ThumbnailProvider.observe(db) val thumbnail_load_state = MediaItemThumbnailLoader.rememberItemState(artist, db) - LaunchedEffect(artist.id, browse_params) { - if (browse_params == null) { - load_error = null - artist.loadData(db, false).onFailure { error -> - load_error = error - } - } - } - LaunchedEffect(artist.id, browse_params) { browse_params_rows = null @@ -123,6 +120,7 @@ fun ArtistPage( val player = LocalPlayerState.current val screen_width = SpMp.context.getScreenWidth() + val coroutine_scope = rememberCoroutineScope() val main_column_state = rememberLazyListState() var show_info by remember { mutableStateOf(false) } @@ -222,178 +220,202 @@ fun ArtistPage( } } - LazyColumn(Modifier.fillMaxSize(), main_column_state, contentPadding = PaddingValues(bottom = bottom_padding)) { + SwipeRefresh( + state = refreshed && loading, + onRefresh = { + refreshed = true + load_error = null + coroutine_scope.launch { + MediaItemLoader.loadArtist(artist.getEmptyData(), db) + } + }, + swipe_enabled = !loading, + modifier = Modifier.fillMaxSize() + ) { + LazyColumn(Modifier.fillMaxSize(), main_column_state, contentPadding = PaddingValues(bottom = bottom_padding)) { - val play_button_size = 55.dp - val filter_bar_height = 32.dp + val play_button_size = 55.dp + val filter_bar_height = 32.dp - // Image spacing - item { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1.1f) - .brushBackground { - Brush.verticalGradient( - 1f - gradient_size to Color.Transparent, - 1f to Theme.background - ) - }, - contentAlignment = Alignment.BottomCenter - ) { - TitleBar( - artist, + // Image spacing + item { + Box( Modifier - .offset { - IntOffset(0, (main_column_state.firstVisibleItemScrollOffset * ARTIST_IMAGE_SCROLL_MODIFIER).toInt()) - } - .padding(bottom = (play_button_size - filter_bar_height) / 2f) - ) + .fillMaxWidth() + .aspectRatio(1.1f) + .brushBackground { + Brush.verticalGradient( + 1f - gradient_size to Color.Transparent, + 1f to Theme.background + ) + }, + contentAlignment = Alignment.BottomCenter + ) { + TitleBar( + artist, + Modifier + .offset { + IntOffset(0, (main_column_state.firstVisibleItemScrollOffset * ARTIST_IMAGE_SCROLL_MODIFIER).toInt()) + } + .padding(bottom = (play_button_size - filter_bar_height) / 2f) + ) + } } - } - // Action / play button bar - item { - Box( - background_modifier.padding(bottom = 20.dp, end = 10.dp).fillMaxWidth().requiredHeight(filter_bar_height), - contentAlignment = Alignment.CenterEnd - ) { - LazyRow( - Modifier.fillMaxWidth().padding(end = play_button_size / 2), - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = content_padding.copy(end = content_padding.calculateEndPadding(LocalLayoutDirection.current) + (play_button_size / 2)), + // Action / play button bar + item { + Box( + background_modifier.padding(bottom = 20.dp, end = 10.dp).fillMaxWidth().requiredHeight(filter_bar_height), + contentAlignment = Alignment.CenterEnd ) { - fun chip(text: String, icon: ImageVector, onClick: () -> Unit) { - item { - ElevatedAssistChip( - onClick, - { Text(text, style = typography.labelLarge) }, - Modifier.height(filter_bar_height), - leadingIcon = { - Icon(icon, null, tint = accent_colour ?: Color.Unspecified) - }, - colors = AssistChipDefaults.assistChipColors( - containerColor = Theme.background, - labelColor = Theme.on_background, - leadingIconContentColor = accent_colour ?: Color.Unspecified + LazyRow( + Modifier.fillMaxWidth().padding(end = play_button_size / 2), + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = content_padding.copy(end = content_padding.calculateEndPadding(LocalLayoutDirection.current) + (play_button_size / 2)), + ) { + fun chip(text: String, icon: ImageVector, onClick: () -> Unit) { + item { + ElevatedAssistChip( + onClick, + { Text(text, style = typography.labelLarge) }, + Modifier.height(filter_bar_height), + leadingIcon = { + Icon(icon, null, tint = accent_colour ?: Color.Unspecified) + }, + colors = AssistChipDefaults.assistChipColors( + containerColor = Theme.background, + labelColor = Theme.on_background, + leadingIconContentColor = accent_colour ?: Color.Unspecified + ) ) - ) + } } - } - chip(getString("artist_chip_shuffle"), Icons.Outlined.Shuffle) { player.playMediaItem(artist, true) } + chip(getString("artist_chip_shuffle"), Icons.Outlined.Shuffle) { player.playMediaItem(artist, true) } - if (SpMp.context.canShare()) { - chip(getString("action_share"), Icons.Outlined.Share) { SpMp.context.shareText(artist.getURL(), artist.Title.get(db) ?: "") } - } - if (SpMp.context.canOpenUrl()) { - chip(getString("artist_chip_open"), Icons.Outlined.OpenInNew) { SpMp.context.openUrl(artist.getURL()) } + if (SpMp.context.canShare()) { + chip(getString("action_share"), Icons.Outlined.Share) { SpMp.context.shareText(artist.getURL(), artist.Title.get(db) ?: "") } + } + if (SpMp.context.canOpenUrl()) { + chip(getString("artist_chip_open"), Icons.Outlined.OpenInNew) { SpMp.context.openUrl(artist.getURL()) } + } + + chip(getString("artist_chip_details"), Icons.Outlined.Info) { show_info = !show_info } } - chip(getString("artist_chip_details"), Icons.Outlined.Info) { show_info = !show_info } + Box(Modifier.requiredHeight(filter_bar_height)) { + ShapedIconButton( + { player.playMediaItem(artist) }, + Modifier.requiredSize(play_button_size), + colours = IconButtonDefaults.iconButtonColors( + containerColor = accent_colour ?: LocalContentColor.current, + contentColor = (accent_colour ?: LocalContentColor.current).getContrasted() + ) + ) { + Icon(Icons.Default.PlayArrow, null) + } + } } + } - Box(Modifier.requiredHeight(filter_bar_height)) { - ShapedIconButton( - { player.playMediaItem(artist) }, - Modifier.requiredSize(play_button_size), - colours = IconButtonDefaults.iconButtonColors( - containerColor = accent_colour ?: LocalContentColor.current, - contentColor = (accent_colour ?: LocalContentColor.current).getContrasted() - ) - ) { - Icon(Icons.Default.PlayArrow, null) + if (load_error != null) { + item { + load_error?.also { error -> + ErrorInfoDisplay(error, background_modifier.fillMaxSize()) } } } - } - - if (load_error != null) { - TODO(load_error.toString()) - } - else if ((browse_params != null && browse_params_rows == null) || (browse_params == null && item_layouts == null)) { - item { - Box(background_modifier.fillMaxSize().padding(content_padding), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = accent_colour ?: Color.Unspecified) + else if (loading) { + item { + Box(background_modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + SubtleLoadingIndicator() + } } } - } - else if (browse_params != null) { - items(browse_params_rows.orEmpty()) { row -> - MediaItemList( - row.items, - background_modifier.padding(content_padding), - title = row.title?.let { title -> - LocalisedYoutubeString.Type.RAW.create(title) + else if ((browse_params != null && browse_params_rows == null) || (browse_params == null && item_layouts == null)) { + item { + Box(background_modifier.fillMaxSize().padding(content_padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = accent_colour ?: Color.Unspecified) } - ) + } } - } - else if (single_layout != null) { - item { - single_layout.TitleBar(background_modifier.padding(content_padding).padding(bottom = 5.dp)) + else if (browse_params != null) { + items(browse_params_rows.orEmpty()) { row -> + MediaItemList( + row.items, + background_modifier.padding(content_padding), + title = row.title?.let { title -> + LocalisedYoutubeString.Type.RAW.create(title) + } + ) + } } + else if (single_layout != null) { + item { + single_layout.TitleBar(background_modifier.padding(content_padding).padding(bottom = 5.dp)) + } - items(single_layout.items) { item -> - Row(background_modifier.padding(content_padding), verticalAlignment = Alignment.CenterVertically) { - MediaItemPreviewLong( - item, - multiselect_context = multiselect_context - ) + items(single_layout.items) { item -> + Row(background_modifier.padding(content_padding), verticalAlignment = Alignment.CenterVertically) { + MediaItemPreviewLong( + item, + multiselect_context = multiselect_context + ) + } } } - } - else { - item { - Column( - background_modifier - .fillMaxSize() - .padding(content_padding), - verticalArrangement = Arrangement.spacedBy(30.dp) - ) { - for (artist_layout in item_layouts ?: emptyList()) { - val layout = artist_layout.rememberMediaItemLayout(db) - val is_singles = - Settings.KEY_TREAT_SINGLES_AS_SONG.get() && layout.title?.getID() == YoutubeUILocalisation.StringID.ARTIST_PAGE_SINGLES - - CompositionLocalProvider(LocalPlayerState provides remember { - if (!is_singles) player - else player.copy( - onClickedOverride = { item, multiselect_key -> - if (item is Playlist) { - onSinglePlaylistClicked(item, player) - } else { - player.onMediaItemClicked(item, multiselect_key) + else { + item { + Column( + background_modifier + .fillMaxSize() + .padding(content_padding), + verticalArrangement = Arrangement.spacedBy(30.dp) + ) { + for (artist_layout in item_layouts ?: emptyList()) { + val layout = artist_layout.rememberMediaItemLayout(db) + val is_singles = + Settings.KEY_TREAT_SINGLES_AS_SONG.get() && layout.title?.getID() == YoutubeUILocalisation.StringID.ARTIST_PAGE_SINGLES + + CompositionLocalProvider(LocalPlayerState provides remember { + if (!is_singles) player + else player.copy( + onClickedOverride = { item, multiselect_key -> + if (item is Playlist) { + onSinglePlaylistClicked(item, player) + } else { + player.onMediaItemClicked(item, multiselect_key) + } + }, + onLongClickedOverride = { item, long_press_data -> + player.onMediaItemLongClicked( + item, + if (item is Playlist) + long_press_data?.copy(playlist_as_song = true) + ?: LongPressMenuData(item, playlist_as_song = true) + else long_press_data + ) } - }, - onLongClickedOverride = { item, long_press_data -> - player.onMediaItemLongClicked( - item, - if (item is Playlist) - long_press_data?.copy(playlist_as_song = true) - ?: LongPressMenuData(item, playlist_as_song = true) - else long_press_data - ) - } - ) - }) { - val type = - if (layout.type == null) MediaItemLayout.Type.GRID - else if (layout.type == MediaItemLayout.Type.NUMBERED_LIST && artist is Artist) MediaItemLayout.Type.LIST - else layout.type - - type.Layout( - if (previous_item == null) layout else layout.copy(title = null, subtitle = null), - multiselect_context = multiselect_context, - apply_filter = apply_filter - ) + ) + }) { + val type = + if (layout.type == null) MediaItemLayout.Type.GRID + else if (layout.type == MediaItemLayout.Type.NUMBERED_LIST && artist is Artist) MediaItemLayout.Type.LIST + else layout.type + + type.Layout( + if (previous_item == null) layout else layout.copy(title = null, subtitle = null), + multiselect_context = multiselect_context, + apply_filter = apply_filter + ) + } } - } - val artist_description: String? by artist.Description.observe(db) - artist_description?.also { description -> - if (description?.isNotBlank() == true) { - DescriptionCard(description, { Theme.background }, { accent_colour }) { show_info = !show_info } + val artist_description: String? by artist.Description.observe(db) + artist_description?.also { description -> + if (description?.isNotBlank() == true) { + DescriptionCard(description, { Theme.background }, { accent_colour }) { show_info = !show_info } + } } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistFooter.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistFooter.kt index 0a9ba30e8..167043467 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistFooter.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistFooter.kt @@ -3,14 +3,15 @@ package com.toasterofbread.spmp.ui.layout.playlistpage import LocalPlayerState import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.Button import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -18,43 +19,62 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.toasterofbread.spmp.model.mediaitem.MediaItem import com.toasterofbread.spmp.model.mediaitem.Playlist import com.toasterofbread.spmp.model.mediaitem.loader.MediaItemLoader +import com.toasterofbread.spmp.resources.getString +import com.toasterofbread.spmp.ui.component.ErrorInfoDisplay import com.toasterofbread.spmp.ui.component.mediaitemlayout.MediaItemLayout import com.toasterofbread.utils.composable.SubtleLoadingIndicator -import com.toasterofbread.utils.getContrasted import kotlinx.coroutines.launch @Composable -fun PlaylistFooter(playlist: Playlist, loading: Boolean, modifier: Modifier = Modifier) { +fun PlaylistFooter(playlist: Playlist, items: List>?, loading: Boolean, load_error: Throwable?, modifier: Modifier = Modifier) { val db = LocalPlayerState.current.context.database val continuation: MediaItemLayout.Continuation? by playlist.Continuation.observe(db) val coroutine_scope = rememberCoroutineScope() Crossfade( - Pair(loading, continuation), + if (load_error != null) load_error + else if (continuation != null) continuation + else if (loading) true + else if (items?.isEmpty() == true) false + else null, modifier - ) { - val (playlist_loading, playlist_continuation) = it - - if (loading || playlist_continuation != null) { - Box(Modifier.fillMaxSize().heightIn(min = 50.dp), contentAlignment = Alignment.Center) { - if (playlist_continuation != null) { - Button({ - coroutine_scope.launch { - MediaItemLoader.loadPlaylist(playlist.getEmptyData(), db, playlist_continuation) - } - }) { - if (playlist_loading) { - SubtleLoadingIndicator() - } - else { - Icon(Icons.Default.KeyboardArrowDown, null) + ) { state -> + when (state) { + is Throwable -> { + ErrorInfoDisplay( + state, + Modifier.fillMaxWidth(), + expanded_modifier = Modifier.height(500.dp), + message = "Playlist load failed" + ) + } + false -> { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(getString("playlist_empty"), Modifier.padding(top = 15.dp)) + } + } + is MediaItemLayout.Continuation, true -> { + Box(Modifier.fillMaxSize().heightIn(min = 50.dp), contentAlignment = Alignment.Center) { + if (state is MediaItemLayout.Continuation) { + Button({ + coroutine_scope.launch { + MediaItemLoader.loadPlaylist(playlist.getEmptyData(), db, state) + } + }) { + if (loading) { + SubtleLoadingIndicator() + } + else { + Icon(Icons.Default.KeyboardArrowDown, null) + } } } - } - else { - SubtleLoadingIndicator() + else { + SubtleLoadingIndicator() + } } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistItems.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistItems.kt index ed6a9b74b..ae2f8bc32 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistItems.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistItems.kt @@ -45,22 +45,14 @@ internal fun LazyListScope.PlaylistItems( playlist: Playlist, loading: Boolean, list_state: ReorderableLazyListState, - sorted_items: List>, + sorted_items: List>?, multiselect_context: MediaItemMultiSelectContext, reorderable: Boolean, sort_option: MediaItemSortOption, player: PlayerState, db: Database ) { - item { - AnimatedVisibility(!loading && sorted_items.isEmpty()) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text(getString("playlist_empty"), Modifier.padding(top = 15.dp)) - } - } - } - - items(sorted_items, key = { it.second }) { + items(sorted_items ?: emptyList(), key = { it.second }) { val (item, index) = it check(item is Song) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt index a56cee51c..bd8dff21a 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt @@ -65,11 +65,12 @@ fun PlaylistPage( val player = LocalPlayerState.current val coroutine_scope = rememberCoroutineScope() - val loading by playlist.loadDataOnChange(db) + var load_error: Throwable? by remember { mutableStateOf(null) } + val loading by playlist.loadDataOnChange(db) { load_error = it } var refreshed by remember { mutableStateOf(false) } val playlist_items: List? by playlist.Items.observe(db) - val sorted_items: MutableList> = remember { mutableStateListOf() } + var sorted_items: List>? by remember { mutableStateOf(null) } val playlist_editor = playlist.rememberEditorOrNull(db) val apply_item_filter: Boolean by Settings.KEY_FILTER_APPLY_TO_PLAYLIST_ITEMS.rememberMutableState() @@ -126,8 +127,7 @@ fun PlaylistPage( // val thumb_item = playlist.getThumbnailHolder().getHolder() LaunchedEffect(playlist_items, current_sort_option, current_filter, apply_item_filter) { - sorted_items.clear() - playlist_items?.also { items -> + sorted_items = playlist_items?.let { items -> val filtered_items = current_filter.let { filter -> items.filter { item -> if (filter != null && item.Title.get(db)?.contains(filter, true) != true) { @@ -142,13 +142,11 @@ fun PlaylistPage( } } - sorted_items.addAll( - current_sort_option - .sortItems(filtered_items, db) - .mapIndexed { index, value -> - Pair(value, index) - } - ) + current_sort_option + .sortItems(filtered_items, db) + .mapIndexed { index, value -> + Pair(value, index) + } } } @@ -160,7 +158,9 @@ fun PlaylistPage( check(current_sort_option == MediaItemSortOption.NATIVE) if (to.index >= items_above && from.index >= items_above) { - sorted_items.add(to.index - items_above, sorted_items.removeAt(from.index - items_above)) + sorted_items = sorted_items?.toMutableList()?.apply { + add(to.index - items_above, removeAt(from.index - items_above)) + } } }, onDragEnd = { from, to -> @@ -189,6 +189,7 @@ fun PlaylistPage( state = refreshed && loading, onRefresh = { refreshed = true + load_error = null coroutine_scope.launch { MediaItemLoader.loadPlaylist(playlist.getEmptyData(), db) } @@ -275,7 +276,7 @@ fun PlaylistPage( ) item { - PlaylistFooter(playlist, loading && !refreshed, Modifier.fillMaxWidth()) + PlaylistFooter(playlist, sorted_items, loading && !refreshed, load_error, Modifier.fillMaxWidth()) } } }