theme the artist header from cover art and tune the mini player

This commit is contained in:
austinried
2026-10-04 00:29:54 +00:00
parent 39e2c5d09e
commit d26936c251
8 changed files with 25 additions and 89 deletions
@@ -65,15 +65,6 @@ class SourceRepository(
fun streamUri(songId: String): String? = active?.streamUri(songId)?.toString() 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( suspend fun addSource(
name: String, name: String,
address: String, address: String,
@@ -6,7 +6,6 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent") private fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent")
private fun fetchRank(type: String): Map<String, Long> { private fun fetchRank(type: String): Map<String, Long> {
@@ -110,20 +110,6 @@ object SubsonicXml {
position = index.toLong(), 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<Element> { private fun Element.elements(tag: String): List<Element> {
@@ -70,7 +70,7 @@ private object Routes {
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val ADD_SERVER = "add-server" const val ADD_SERVER = "add-server"
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}" 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}" const val PLAYLIST_DETAIL = "playlist/{playlistId}"
fun album( fun album(
@@ -78,7 +78,10 @@ private object Routes {
coverArt: String? = null, coverArt: String? = null,
) = "album/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}" ) = "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)}" fun playlist(id: String) = "playlist/${Uri.encode(id)}"
} }
@@ -140,7 +143,7 @@ private fun MainNavigation() {
composable(Routes.LIBRARY) { composable(Routes.LIBRARY) {
LibraryRoute( LibraryRoute(
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, 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)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) },
bottomInset = if (playerVisible) 0.dp else navBarInset, bottomInset = if (playerVisible) 0.dp else navBarInset,
@@ -175,10 +178,22 @@ private fun MainNavigation() {
} }
composable( composable(
route = Routes.ARTIST_DETAIL, 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 -> ) { entry ->
ArtistDetailRoute( ArtistDetailRoute(
artistId = entry.arguments?.getString("artistId").orEmpty(), artistId = entry.arguments?.getString("artistId").orEmpty(),
coverArtId =
entry.arguments
?.getString("coverArt")
.orEmpty()
.ifEmpty { null },
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) }, onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
) )
@@ -71,6 +71,7 @@ private const val THEME_TRANSITION_MS = 100
@Composable @Composable
fun ArtistDetailRoute( fun ArtistDetailRoute(
artistId: String, artistId: String,
coverArtId: String?,
onBack: () -> Unit, onBack: () -> Unit,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
@@ -79,12 +80,13 @@ fun ArtistDetailRoute(
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList()) val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
ArtistDetailScreen( ArtistDetailScreen(
artist = artist, artist = artist,
albums = albums, albums = albums,
art = art, art = art,
artThumbnail = artThumbnail, artThumbnail = artThumbnail,
artwork = rememberArtworkColors(artThumbnail ?: art, THEME_TRANSITION_MS), artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS),
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@@ -8,12 +8,10 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -40,29 +38,13 @@ class ArtistDetailViewModel(
viewModelScope.launch { viewModelScope.launch {
artist artist
.filterNotNull() .filterNotNull()
.distinctUntilChangedBy { it.id }
.collectLatest { loaded -> .collectLatest { loaded ->
val (full, thumbnail) = resolveArt(loaded) _art.value = sourceRepository.coverArt(loaded.coverArt)
_art.value = full _artThumbnail.value = sourceRepository.coverArt(loaded.coverArt, thumbnail = true)
_artThumbnail.value = thumbnail
} }
} }
} }
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
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( fun coverArt(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
@@ -46,7 +46,7 @@ fun MiniPlayer(
val item = state.item ?: return val item = state.item ?: return
ArtworkTheme(artwork) { ArtworkTheme(artwork) {
Surface( Surface(
color = artwork?.accents?.firstOrNull() ?: MaterialTheme.colorScheme.surfaceContainerHigh, color = artwork?.gradientHigh ?: MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
modifier = modifier =
modifier.fillMaxWidth().clickable( modifier.fillMaxWidth().clickable(
@@ -96,29 +96,4 @@ class SubsonicXmlTest {
assertEquals("sg1", entries[0].songId) assertEquals("sg1", entries[0].songId)
assertEquals(251L, SubsonicXml.song(1, document.getElementsByTagName("entry").item(0) as org.w3c.dom.Element).duration) assertEquals(251L, SubsonicXml.song(1, document.getElementsByTagName("entry").item(0) as org.w3c.dom.Element).duration)
} }
@Test
fun mapsArtistImageUrls() {
val document =
parse(
"""
<subsonic-response status="ok">
<artistInfo2>
<smallImageUrl>https://example.com/small.jpg</smallImageUrl>
<largeImageUrl>https://example.com/large.jpg</largeImageUrl>
</artistInfo2>
</subsonic-response>
""".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("""<subsonic-response status="ok"><artistInfo2/></subsonic-response>""")
assertEquals(null, SubsonicXml.artistImageUrl(document, thumbnail = false))
}
} }