mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
theme the artist header from cover art and tune the mini player
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user