fetch full-res artist art and match the old artist layout

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 53875b3640
commit dd660597fb
7 changed files with 132 additions and 46 deletions
@@ -65,6 +65,15 @@ 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,
@@ -93,6 +93,16 @@ class SubsonicSource(
)
}
suspend fun artistArtUri(
artistId: String,
thumbnail: Boolean = false,
): String? =
withContext(Dispatchers.IO) {
runCatching {
SubsonicXml.artistImageUrl(client.get("getArtistInfo2", mapOf("id" to artistId)), thumbnail)
}.getOrNull()
}
private fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent")
private fun fetchRank(type: String): Map<String, Long> {
@@ -109,6 +109,25 @@ 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> {
val nodes = getElementsByTagName(tag)
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element }
}
private fun Document.elements(tag: String): List<Element> {
@@ -2,19 +2,17 @@ package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@@ -27,7 +25,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
@@ -49,9 +46,11 @@ fun ArtistDetailRoute(
) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
val art by viewModel.art.collectAsStateWithLifecycle()
ArtistDetailScreen(
artist = artist,
albums = albums,
art = art,
coverArt = viewModel::coverArt,
onBack = onBack,
onAlbumClick = onAlbumClick,
@@ -63,6 +62,7 @@ fun ArtistDetailRoute(
fun ArtistDetailScreen(
artist: Artist?,
albums: List<Album>,
art: CoverArtRef?,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
onAlbumClick: (Album) -> Unit,
@@ -72,7 +72,14 @@ fun ArtistDetailScreen(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(artist?.name.orEmpty(), maxLines = 1) },
title = {
Text(
text = artist?.name.orEmpty(),
style = MaterialTheme.typography.headlineLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
@@ -81,50 +88,50 @@ fun ArtistDetailScreen(
)
},
) { padding ->
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(padding).fillMaxSize(),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
BoxWithConstraints(Modifier.padding(padding).fillMaxSize()) {
val artHeight = maxHeight * 0.3f
Column(Modifier.fillMaxSize()) {
CoverArt(
ref = art,
name = artist?.name.orEmpty(),
modifier = Modifier.fillMaxWidth().height(artHeight),
)
Text(
text = "${albums.size} albums",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth().weight(1f),
) {
CoverArt(
ref = coverArt(artist?.coverArt, false),
name = artist?.name.orEmpty(),
modifier = Modifier.size(120.dp).clip(CircleShape),
)
Column {
Text(artist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
Text(
text = "${albums.size} albums",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
items(albums, key = { it.id }) { album ->
Column(Modifier.clickable { onAlbumClick(album) }) {
CoverArt(
ref = coverArt(album.coverArt, false),
name = album.name,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
)
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
items(albums, key = { it.id }) { album ->
Column(Modifier.clickable { onAlbumClick(album) }) {
CoverArt(
ref = coverArt(album.coverArt, true),
name = album.name,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
)
Text(
text = album.name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
}
@@ -1,6 +1,7 @@
package com.subtracks.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
@@ -8,8 +9,12 @@ import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class ArtistDetailViewModel(
@@ -23,6 +28,16 @@ class ArtistDetailViewModel(
val albums: Flow<List<Album>> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) }
private val _art = MutableStateFlow<CoverArtRef?>(null)
val art: StateFlow<CoverArtRef?> = _art
init {
viewModelScope.launch {
val loaded = artist.filterNotNull().first()
_art.value = sourceRepository.artistArt(loaded.id) ?: sourceRepository.coverArt(loaded.coverArt)
}
}
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -96,4 +96,29 @@ 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))
}
}
@@ -138,6 +138,7 @@ class AppScreenshotTest {
ArtistDetailScreen(
artist = Fixtures.artists.first(),
albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
art = CoverArtRef("art-ar-radiohead", "test:art-ar-radiohead"),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onAlbumClick = {},