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() 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,
@@ -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 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> {
@@ -109,6 +109,25 @@ 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> {
val nodes = getElementsByTagName(tag)
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element }
} }
private fun Document.elements(tag: String): List<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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -49,9 +46,11 @@ fun ArtistDetailRoute(
) { ) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null) val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList()) val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
val art by viewModel.art.collectAsStateWithLifecycle()
ArtistDetailScreen( ArtistDetailScreen(
artist = artist, artist = artist,
albums = albums, albums = albums,
art = art,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@@ -63,6 +62,7 @@ fun ArtistDetailRoute(
fun ArtistDetailScreen( fun ArtistDetailScreen(
artist: Artist?, artist: Artist?,
albums: List<Album>, albums: List<Album>,
art: CoverArtRef?,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
@@ -72,7 +72,14 @@ fun ArtistDetailScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(artist?.name.orEmpty(), maxLines = 1) }, title = {
Text(
text = artist?.name.orEmpty(),
style = MaterialTheme.typography.headlineLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
@@ -81,50 +88,50 @@ fun ArtistDetailScreen(
) )
}, },
) { padding -> ) { padding ->
LazyVerticalGrid( BoxWithConstraints(Modifier.padding(padding).fillMaxSize()) {
columns = GridCells.Fixed(3), val artHeight = maxHeight * 0.3f
contentPadding = PaddingValues(16.dp), Column(Modifier.fillMaxSize()) {
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,
) {
CoverArt( CoverArt(
ref = coverArt(artist?.coverArt, false), ref = art,
name = artist?.name.orEmpty(), name = artist?.name.orEmpty(),
modifier = Modifier.size(120.dp).clip(CircleShape), modifier = Modifier.fillMaxWidth().height(artHeight),
) )
Column {
Text(artist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge)
Text( Text(
text = "${albums.size} albums", text = "${albums.size} albums",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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),
) {
items(albums, key = { it.id }) { album -> items(albums, key = { it.id }) { album ->
Column(Modifier.clickable { onAlbumClick(album) }) { Column(Modifier.clickable { onAlbumClick(album) }) {
CoverArt( CoverArt(
ref = coverArt(album.coverArt, true), ref = coverArt(album.coverArt, false),
name = album.name, name = album.name,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
) )
Text( Text(
text = album.name, text = album.name,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 6.dp),
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
} }
}
}
} }
@@ -1,6 +1,7 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
@@ -8,8 +9,12 @@ 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.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class ArtistDetailViewModel( class ArtistDetailViewModel(
@@ -23,6 +28,16 @@ class ArtistDetailViewModel(
val albums: Flow<List<Album>> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) } 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( fun coverArt(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
@@ -96,4 +96,29 @@ 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))
}
} }
@@ -138,6 +138,7 @@ class AppScreenshotTest {
ArtistDetailScreen( ArtistDetailScreen(
artist = Fixtures.artists.first(), artist = Fixtures.artists.first(),
albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id }, 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") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {}, onBack = {},
onAlbumClick = {}, onAlbumClick = {},