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 26d07a6c..cb9282fe 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -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, 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 ab9cb5a0..019bb3d5 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 @@ -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> = 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 7d5b6d64..2f635fb4 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 @@ -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 { + val nodes = getElementsByTagName(tag) + return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element } } private fun Document.elements(tag: String): List { 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 a4d279e7..28c246e3 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -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, + 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), - ) - } - } } } } 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 3b309589..4e92a537 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -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> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) } + private val _art = MutableStateFlow(null) + val art: StateFlow = _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, 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 1ea43265..c4570733 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,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( + """ + + + 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)) + } } diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 34864397..dc7fa83e 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -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 = {},