mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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()
|
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,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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 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> {
|
||||||
|
|||||||
@@ -110,20 +110,6 @@ 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> {
|
private fun Element.elements(tag: String): List<Element> {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ private object Routes {
|
|||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val ADD_SERVER = "add-server"
|
const val ADD_SERVER = "add-server"
|
||||||
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
|
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}"
|
const val PLAYLIST_DETAIL = "playlist/{playlistId}"
|
||||||
|
|
||||||
fun album(
|
fun album(
|
||||||
@@ -78,7 +78,10 @@ private object Routes {
|
|||||||
coverArt: String? = null,
|
coverArt: String? = null,
|
||||||
) = "album/${Uri.encode(id)}?coverArt=${Uri.encode(coverArt.orEmpty())}"
|
) = "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)}"
|
fun playlist(id: String) = "playlist/${Uri.encode(id)}"
|
||||||
}
|
}
|
||||||
@@ -140,7 +143,7 @@ private fun MainNavigation() {
|
|||||||
composable(Routes.LIBRARY) {
|
composable(Routes.LIBRARY) {
|
||||||
LibraryRoute(
|
LibraryRoute(
|
||||||
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
|
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)) },
|
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
|
||||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
bottomInset = if (playerVisible) 0.dp else navBarInset,
|
bottomInset = if (playerVisible) 0.dp else navBarInset,
|
||||||
@@ -175,10 +178,22 @@ private fun MainNavigation() {
|
|||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.ARTIST_DETAIL,
|
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 ->
|
) { entry ->
|
||||||
ArtistDetailRoute(
|
ArtistDetailRoute(
|
||||||
artistId = entry.arguments?.getString("artistId").orEmpty(),
|
artistId = entry.arguments?.getString("artistId").orEmpty(),
|
||||||
|
coverArtId =
|
||||||
|
entry.arguments
|
||||||
|
?.getString("coverArt")
|
||||||
|
.orEmpty()
|
||||||
|
.ifEmpty { null },
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
|
onAlbumClick = { album -> navController.navigate(Routes.album(album.id, album.coverArt)) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ private const val THEME_TRANSITION_MS = 100
|
|||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailRoute(
|
fun ArtistDetailRoute(
|
||||||
artistId: String,
|
artistId: String,
|
||||||
|
coverArtId: String?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAlbumClick: (Album) -> Unit,
|
onAlbumClick: (Album) -> Unit,
|
||||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||||
@@ -79,12 +80,13 @@ fun ArtistDetailRoute(
|
|||||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
|
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||||
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
albums = albums,
|
albums = albums,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
artwork = rememberArtworkColors(artThumbnail ?: art, THEME_TRANSITION_MS),
|
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
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.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -40,29 +38,13 @@ class ArtistDetailViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
artist
|
artist
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.distinctUntilChangedBy { it.id }
|
|
||||||
.collectLatest { loaded ->
|
.collectLatest { loaded ->
|
||||||
val (full, thumbnail) = resolveArt(loaded)
|
_art.value = sourceRepository.coverArt(loaded.coverArt)
|
||||||
_art.value = full
|
_artThumbnail.value = sourceRepository.coverArt(loaded.coverArt, thumbnail = true)
|
||||||
_artThumbnail.value = thumbnail
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ fun MiniPlayer(
|
|||||||
val item = state.item ?: return
|
val item = state.item ?: return
|
||||||
ArtworkTheme(artwork) {
|
ArtworkTheme(artwork) {
|
||||||
Surface(
|
Surface(
|
||||||
color = artwork?.accents?.firstOrNull() ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = artwork?.gradientHigh ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier =
|
modifier =
|
||||||
modifier.fillMaxWidth().clickable(
|
modifier.fillMaxWidth().clickable(
|
||||||
|
|||||||
@@ -96,29 +96,4 @@ 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user