feat(web+android): Folgen-Herz ins Künstler-Banner, Favoriten zeigen Künstler und Alben

Das Herz sitzt jetzt im Banner neben dem Pin-Zahnrad statt in der
Aktionsleiste: die Leiste ist "was ich jetzt tue" (abspielen, mischen,
Konzerte), Folgen ist ein Zustand und gehört an den Künstler. Auf beiden
Plattformen gleich. Beim Album bleibt das Herz in der Aktionsleiste, dort
gibt es kein Banner.

Dazu die Übersicht, wem man folgt: die Favoritenseite bekommt Abschnitte
Künstler / Alben / Songs. Ohne zusätzlichen Aufruf — getStarred2 liefert
Künstler und Alben im selben Paket wie die Songs, wir haben sie bisher
verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-18 13:11:18 +02:00
1 parent 5f043beb5d
commit 19f241e493
8 files changed
+190 -54

No files matched your search

@@ -415,6 +415,8 @@ fun AppNavigation() {
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
)
}
composable(TopLevel.ABS.route) {
@@ -263,6 +263,24 @@ fun ArtistDetailScreen(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste darunter: die Leiste
// ist "was ich jetzt tue", Folgen ist ein Zustand. Deshalb als Herz ins Banner,
// direkt neben das Zahnrad — genau wie im Web.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val folgt = overrides[artist.id] ?: (artist.starred != null)
Surface(
onClick = { vm.folgenUmschalten(artist.id, artist.starred != null) },
shape = RoundedCornerShape(999.dp),
color = Color(0x59000000),
modifier = Modifier.align(Alignment.TopEnd).padding(top = 10.dp, end = 52.dp),
) {
Icon(
if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (folgt) "Du folgst ${artist.name}" else "${artist.name} folgen",
tint = if (folgt) NsAccent else Color.White.copy(0.72f),
modifier = Modifier.padding(7.dp).size(17.dp),
)
}
// "Wrong artist?" pin — minimal icon-only chip, top-right over the hero.
Surface(
onClick = { vm.openPinPicker() },
@@ -323,18 +341,6 @@ fun ArtistDetailScreen(
onClick = { vm.shuffleAll() },
)
}
item {
// Folgen = Navidromes Stern auf dem Kuenstler. StarState kennt keine Songs,
// sondern IDs — und Navidrome behandelt Song-, Album- und Kuenstler-IDs beim
// Markieren gleich, der vorhandene Weg traegt also ohne Aenderung.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val folgt = overrides[artist.id] ?: (artist.starred != null)
ArtistActionPill(
icon = if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
label = if (folgt) "Folge ich" else "Folgen",
hervorgehoben = folgt,
) { vm.folgenUmschalten(artist.id, artist.starred != null) }
}
item {
val eventCount = state.events.size
val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte"
@@ -600,26 +606,20 @@ fun ArtistDetailScreen(
}
@Composable
private fun ArtistActionPill(
icon: ImageVector,
label: String,
hervorgehoben: Boolean = false,
onClick: () -> Unit,
) {
val farbe = if (hervorgehoben) NsAccent else NsOnSurface
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(NsSurface)
.border(1.dp, if (hervorgehoben) NsAccent else NsBorder, RoundedCornerShape(20.dp))
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 9.dp),
) {
Icon(icon, null, tint = farbe, modifier = Modifier.size(16.dp))
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = farbe)
fontSize = 13.sp, color = NsOnSurface)
}
}
@@ -1,8 +1,18 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.novasonic.app.ui.component.AlbumCard
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -28,18 +38,27 @@ fun StarredScreen(
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
) {
val context = LocalContext.current
val repo = context.app.repository
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
// getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — die Uebersicht
// "wem folge ich" kostet also keinen zusaetzlichen Aufruf.
var gefolgteKuenstler by remember { mutableStateOf<List<com.novasonic.app.api.model.Artist>>(emptyList()) }
var gemerkteAlben by remember { mutableStateOf<List<com.novasonic.app.api.model.Album>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
LaunchedEffect(Unit) {
try {
songs = repo.getStarred().song
val markiert = repo.getStarred()
songs = markiert.song
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
loading = false
} catch (e: Exception) {
error = e.message
@@ -100,12 +119,43 @@ fun StarredScreen(
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(error!!, color = MaterialTheme.colorScheme.error)
}
} else if (sorted.isEmpty()) {
} else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Noch keine Favoriten", color = NsOnSurface2)
}
} else {
LazyColumn {
if (gefolgteKuenstler.isNotEmpty()) {
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(gefolgteKuenstler, key = { it.id }) { a ->
GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) }
}
}
Spacer(Modifier.height(8.dp))
}
}
if (gemerkteAlben.isNotEmpty()) {
item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(gemerkteAlben, key = { it.id }) { al ->
AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) }
}
}
Spacer(Modifier.height(8.dp))
}
}
if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) {
item { FavoritenLabel("Songs (${sorted.size})") }
}
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
@@ -122,3 +172,30 @@ fun StarredScreen(
}
}
}
@Composable
private fun FavoritenLabel(titel: String) {
Text(
titel,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp),
)
}
@Composable
private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) {
Column(Modifier.width(110.dp).clickable(onClick = onClick)) {
AsyncImage(
model = bild, contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1)
}
}