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()
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,
@@ -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<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent")
private fun fetchRank(type: String): Map<String, Long> {
@@ -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<Element> {
@@ -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)) },
)
@@ -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,
@@ -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<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(
coverArt: String?,
thumbnail: Boolean,
@@ -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(
@@ -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(
"""
<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))
}
}