mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
fetch full-res artist art and match the old artist layout
This commit is contained in:
@@ -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 = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user