From d26936c251c4a10864b2f3992d86cb0122665fc4 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:43:53 +0000 Subject: [PATCH] theme the artist header from cover art and tune the mini player --- .../subtracks/data/repo/SourceRepository.kt | 9 ------- .../data/source/subsonic/SubsonicSource.kt | 15 ----------- .../data/source/subsonic/SubsonicXml.kt | 14 ----------- .../main/kotlin/com/subtracks/ui/AppRoot.kt | 23 ++++++++++++++--- .../ui/library/ArtistDetailScreen.kt | 4 ++- .../ui/library/ArtistDetailViewModel.kt | 22 ++-------------- .../com/subtracks/ui/playback/MiniPlayer.kt | 2 +- .../data/source/subsonic/SubsonicXmlTest.kt | 25 ------------------- 8 files changed, 25 insertions(+), 89 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index cb9282fe..26d07a6c 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -65,15 +65,6 @@ class SourceRepository( fun streamUri(songId: String): String? = active?.streamUri(songId)?.toString() - suspend fun artistArt( - artistId: String, - thumbnail: Boolean = false, - ): CoverArtRef? { - val source = active ?: return null - val url = source.artistArtUri(artistId, thumbnail) ?: return null - return CoverArtRef(url = url, cacheKey = "${source.id}:artist:$artistId:$thumbnail") - } - suspend fun addSource( name: String, address: String, diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index a7417791..ab9cb5a0 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -6,7 +6,6 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song import com.subtracks.data.source.MusicSource -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -94,20 +93,6 @@ class SubsonicSource( ) } - suspend fun artistArtUri( - artistId: String, - thumbnail: Boolean = false, - ): String? = - withContext(Dispatchers.IO) { - try { - SubsonicXml.artistImageUrl(client.get("getArtistInfo2", mapOf("id" to artistId)), thumbnail) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - null - } - } - private fun fetchRanks(): Pair, Map> = fetchRank("frequent") to fetchRank("recent") private fun fetchRank(type: String): Map { diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index d5e91321..58db9786 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -110,20 +110,6 @@ object SubsonicXml { position = index.toLong(), ) } - - fun artistImageUrl( - document: Document, - thumbnail: Boolean, - ): String? { - val tag = if (thumbnail) "smallImageUrl" else "largeImageUrl" - return document - .elements("artistInfo2") - .firstOrNull() - ?.elements(tag) - ?.firstOrNull() - ?.textContent - ?.takeIf { it.isNotBlank() } - } } private fun Element.elements(tag: String): List { diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 95c6df43..bba11961 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -70,7 +70,7 @@ private object Routes { const val SETTINGS = "settings" const val ADD_SERVER = "add-server" const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" - const val ARTIST_DETAIL = "artist/{artistId}" + const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}" const val PLAYLIST_DETAIL = "playlist/{playlistId}" fun album( @@ -78,7 +78,10 @@ private object Routes { coverArt: String? = null, ) = "album/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}" - fun artist(id: String) = "artist/${Uri.encode(id)}" + fun artist( + id: String, + coverArt: String? = null, + ) = "artist/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}" fun playlist(id: String) = "playlist/${Uri.encode(id)}" } @@ -140,7 +143,7 @@ private fun MainNavigation() { composable(Routes.LIBRARY) { LibraryRoute( onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, - onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id)) }, + onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, bottomInset = if (playerVisible) 0.dp else navBarInset, @@ -175,10 +178,22 @@ private fun MainNavigation() { } composable( route = Routes.ARTIST_DETAIL, - arguments = listOf(navArgument("artistId") { type = NavType.StringType }), + arguments = + listOf( + navArgument("artistId") { type = NavType.StringType }, + navArgument("coverArt") { + type = NavType.StringType + defaultValue = "" + }, + ), ) { entry -> ArtistDetailRoute( artistId = entry.arguments?.getString("artistId").orEmpty(), + coverArtId = + entry.arguments + ?.getString("coverArt") + .orEmpty() + .ifEmpty { null }, onBack = { navController.popBackStack() }, onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index f4cebf50..9c47bb2f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -71,6 +71,7 @@ private const val THEME_TRANSITION_MS = 100 @Composable fun ArtistDetailRoute( artistId: String, + coverArtId: String?, onBack: () -> Unit, onAlbumClick: (Album) -> Unit, viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, @@ -79,12 +80,13 @@ fun ArtistDetailRoute( val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList()) val art by viewModel.art.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() + val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } ArtistDetailScreen( artist = artist, albums = albums, art = art, artThumbnail = artThumbnail, - artwork = rememberArtworkColors(artThumbnail ?: art, THEME_TRANSITION_MS), + artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS), coverArt = viewModel::coverArt, onBack = onBack, onAlbumClick = onAlbumClick, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index 07e088cf..447eb440 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -8,12 +8,10 @@ import com.subtracks.data.model.CoverArtRef import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch @@ -40,29 +38,13 @@ class ArtistDetailViewModel( viewModelScope.launch { artist .filterNotNull() - .distinctUntilChangedBy { it.id } .collectLatest { loaded -> - val (full, thumbnail) = resolveArt(loaded) - _art.value = full - _artThumbnail.value = thumbnail + _art.value = sourceRepository.coverArt(loaded.coverArt) + _artThumbnail.value = sourceRepository.coverArt(loaded.coverArt, thumbnail = true) } } } - private suspend fun resolveArt(artist: Artist): Pair { - repeat(3) { attempt -> - val full = sourceRepository.artistArt(artist.id) ?: sourceRepository.coverArt(artist.coverArt) - if (full != null) { - val thumbnail = - sourceRepository.artistArt(artist.id, thumbnail = true) - ?: sourceRepository.coverArt(artist.coverArt, thumbnail = true) - return full to thumbnail - } - if (attempt < 2) delay(300) - } - return null to null - } - fun coverArt( coverArt: String?, thumbnail: Boolean, diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt index 9af2a45f..3cc2f748 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/MiniPlayer.kt @@ -46,7 +46,7 @@ fun MiniPlayer( val item = state.item ?: return ArtworkTheme(artwork) { Surface( - color = artwork?.accents?.firstOrNull() ?: MaterialTheme.colorScheme.surfaceContainerHigh, + color = artwork?.gradientHigh ?: MaterialTheme.colorScheme.surfaceContainerHigh, contentColor = MaterialTheme.colorScheme.onSurface, modifier = modifier.fillMaxWidth().clickable( diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt index c4570733..1ea43265 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -96,29 +96,4 @@ class SubsonicXmlTest { assertEquals("sg1", entries[0].songId) assertEquals(251L, SubsonicXml.song(1, document.getElementsByTagName("entry").item(0) as org.w3c.dom.Element).duration) } - - @Test - fun mapsArtistImageUrls() { - val document = - parse( - """ - - - https://example.com/small.jpg - https://example.com/large.jpg - - - """.trimIndent(), - ) - - assertEquals("https://example.com/large.jpg", SubsonicXml.artistImageUrl(document, thumbnail = false)) - assertEquals("https://example.com/small.jpg", SubsonicXml.artistImageUrl(document, thumbnail = true)) - } - - @Test - fun artistImageUrlIsNullWhenAbsent() { - val document = parse("""""") - - assertEquals(null, SubsonicXml.artistImageUrl(document, thumbnail = false)) - } }