feat: multi-artist support in NowPlaying (OpenSubsonic artists array)

- Song model: add SongArtist{id,name} + artists: List<SongArtist>
  Navidrome sends this via OpenSubsonic extension — previously ignored
- NowPlaying: if song.artists has >1 entry, render each name as an
  inline AnnotatedString with ClickableText, separated by ' · ';
  every artist navigates to their own page with their real ID
  Falls back to single artist/artistId entry when array is absent
- AppNavigation: onArtistClick now receives artistId: String instead
  of a fixed closure, enabling per-artist navigation from the sheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-15 00:37:08 +02:00
1 parent 0e651e8be3
commit f15c005a3c
3 files changed
+50 -11

No files matched your search

@@ -40,6 +40,12 @@ data class SubsonicError(val code: Int, val message: String)
// ── Domain models ───────────────────────────────────────────────────────────── // ── Domain models ─────────────────────────────────────────────────────────────
@Serializable
data class SongArtist(
val id: String = "",
val name: String = "",
)
@Serializable @Serializable
data class Song( data class Song(
val id: String, val id: String,
@@ -48,6 +54,7 @@ data class Song(
val albumId: String? = null, val albumId: String? = null,
val artist: String? = null, val artist: String? = null,
val artistId: String? = null, val artistId: String? = null,
val artists: List<SongArtist> = emptyList(),
val track: Int? = null, val track: Int? = null,
val year: Int? = null, val year: Int? = null,
val genre: String? = null, val genre: String? = null,
@@ -324,11 +324,9 @@ fun AppNavigation() {
onShowOptions = { onShowOptions = {
playerState.currentSong?.let { s -> contextMenuSong = s } playerState.currentSong?.let { s -> contextMenuSong = s }
}, },
onArtistClick = playerState.currentSong?.artistId?.let { artistId -> onArtistClick = { artistId ->
{
showNowPlaying = false showNowPlaying = false
navController.navigate("artist/$artistId") if (artistId.isNotBlank()) navController.navigate("artist/$artistId")
}
}, },
) )
} }
@@ -33,9 +33,14 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import coil.request.ImageRequest import coil.request.ImageRequest
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.foundation.text.ClickableText
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -59,7 +64,7 @@ fun NowPlayingScreen(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onStarToggle: (() -> Unit)? = null, onStarToggle: (() -> Unit)? = null,
onShowOptions: (() -> Unit)? = null, onShowOptions: (() -> Unit)? = null,
onArtistClick: (() -> Unit)? = null, onArtistClick: ((artistId: String) -> Unit)? = null,
) { ) {
val song = state.currentSong val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) } var tab by remember { mutableStateOf(NpTab.PLAYER) }
@@ -208,17 +213,46 @@ fun NowPlayingScreen(
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 1, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
if (!song?.artist.isNullOrBlank()) { if (song != null && !song.artist.isNullOrBlank()) {
// Build list: prefer song.artists (OpenSubsonic, has real IDs for all),
// fall back to single entry from artist/artistId
val artistList = if (song.artists.size > 1) song.artists
else listOf(com.novasonic.app.api.model.SongArtist(
id = song.artistId ?: "", name = song.artist!!))
if (onArtistClick != null) {
val annotated = buildAnnotatedString {
artistList.forEachIndexed { idx, a ->
if (idx > 0) {
withStyle(SpanStyle(color = Color.White.copy(0.4f),
fontFamily = Outfit, fontSize = 15.sp)) { append(" · ") }
}
pushStringAnnotation("ARTIST_ID", a.id)
withStyle(SpanStyle(color = NsAccent,
fontFamily = Outfit,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp)) { append(a.name) }
pop()
}
}
ClickableText(
text = annotated,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
onClick = { offset ->
annotated.getStringAnnotations("ARTIST_ID", offset, offset)
.firstOrNull()?.let { onArtistClick(it.item) }
},
)
} else {
Text( Text(
song!!.artist!!, song.artist!!,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, color = NsAccent, fontSize = 15.sp, color = NsAccent,
maxLines = 1, overflow = TextOverflow.Ellipsis, maxLines = 2, overflow = TextOverflow.Ellipsis,
modifier = if (onArtistClick != null)
Modifier.clickable(onClick = onArtistClick)
else Modifier,
) )
} }
}
if (!song?.album.isNullOrBlank()) { if (!song?.album.isNullOrBlank()) {
Text( Text(
song!!.album!!, song!!.album!!,