feat(web+android): Künstlern folgen, Alben merken, Top Songs per Künstler-ID
Folgen ist kein eigener Speicher, sondern Navidromes Stern auf dem Künstler (star&id=<ndArtistId>) — Navidrome wirft id, albumId und artistId beim Markieren in eine Liste und behandelt sie gleich, der vorhandene Aufruf trug also ohne Änderung. Zustand aus `starred` in getArtist/getAlbum, beide Plattformen schalten optimistisch um. Android nutzt den vorhandenen StarState, der ohnehin über IDs arbeitet. Dazu: getTopSongs bekommt seit Navidrome 0.64 zusätzlich `id`. Der Namensweg sucht mit LIKE und Max:1, bei gleichnamigen Künstlern gewann der erste Treffer. Jetzt id + artist zusammen; in Erkunden bleibt es beim Namen, weil die artistId dort Deezers ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de674dbe3f
commit
5f043beb5d
11 files changed
+177
-11
No files matched your search
@@ -149,9 +149,16 @@ class SubsonicClient(
|
||||
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
|
||||
?: com.novasonic.app.api.model.ArtistInfo()
|
||||
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
|
||||
?: emptyList()
|
||||
/**
|
||||
* id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
|
||||
* Namen zurueck, wenn es sie nicht kennt; aeltere Versionen ignorieren id. Der Namensweg sucht
|
||||
* mit LIKE und Max:1 — bei gleichnamigen Kuenstlern gewann bisher der erste Treffer.
|
||||
*/
|
||||
suspend fun getTopSongs(artist: String, count: Int = 10, artistId: String? = null): List<com.novasonic.app.api.model.Song> =
|
||||
get("getTopSongs.view", buildMap {
|
||||
put("artist", artist); put("count", count)
|
||||
artistId?.takeIf { it.isNotBlank() }?.let { put("id", it) }
|
||||
}).topSongs?.song ?: emptyList()
|
||||
|
||||
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
|
||||
get("getGenres.view").genres?.genre ?: emptyList()
|
||||
|
||||
@@ -101,7 +101,10 @@ class MusicRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||
/** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
|
||||
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
|
||||
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
|
||||
api().getTopSongs(artistName, artistId = artistId)
|
||||
suspend fun getGenres() = api().getGenres()
|
||||
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
|
||||
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
|
||||
|
||||
@@ -11,6 +11,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import com.novasonic.app.data.StarState
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.*
|
||||
@@ -177,6 +180,25 @@ fun AlbumScreen(
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
|
||||
}
|
||||
// Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und
|
||||
// Kuenstler-IDs beim Markieren gleich.
|
||||
val overrides by StarState.overrides.collectAsStateWithLifecycle()
|
||||
val gemerkt = overrides[album.id] ?: (album.starred != null)
|
||||
OutlinedButton(
|
||||
onClick = { vm.merkenUmschalten(album.id, album.starred != null) },
|
||||
modifier = Modifier.height(46.dp).width(46.dp),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
|
||||
brush = SolidColor(if (gemerkt) NsAccent else NsAccent.copy(alpha = 0.55f))
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
if (gemerkt) "Gemerkt" else "Album merken",
|
||||
Modifier.size(20.dp), tint = NsAccent,
|
||||
)
|
||||
}
|
||||
val isFullyDownloaded = downloadGroup != null &&
|
||||
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
|
||||
downloadGroup!!.totalTracks > 0
|
||||
|
||||
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import com.novasonic.app.data.StarState
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
@@ -320,6 +323,18 @@ 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"
|
||||
@@ -585,20 +600,26 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
private fun ArtistActionPill(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
hervorgehoben: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val farbe = if (hervorgehoben) NsAccent else NsOnSurface
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(NsSurface)
|
||||
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
|
||||
.border(1.dp, if (hervorgehoben) NsAccent else NsBorder, RoundedCornerShape(20.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 18.dp, vertical = 9.dp),
|
||||
) {
|
||||
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
|
||||
Icon(icon, null, tint = farbe, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface)
|
||||
fontSize = 13.sp, color = farbe)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ data class AlbumUiState(
|
||||
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
/** Album merken — optimistisch ueber StarState, faellt bei einem Fehlschlag zurueck. */
|
||||
fun merkenUmschalten(albumId: String, aktuellMarkiert: Boolean) =
|
||||
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, albumId, aktuellMarkiert)
|
||||
private val offlineRepo = app.app.offlineRepository
|
||||
private val albumId: String? = savedState["albumId"]
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ data class ArtistUiState(
|
||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
/** Folgen = Navidromes Stern auf dem Kuenstler. Laeuft ueber StarState, damit der Zustand
|
||||
* sofort umspringt und bei einem Fehlschlag von selbst zurueckfaellt. */
|
||||
fun folgenUmschalten(artistId: String, aktuellMarkiert: Boolean) =
|
||||
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, artistId, aktuellMarkiert)
|
||||
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
@@ -101,7 +107,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
val artist = artistDef.await()
|
||||
val info = infoDef.await()
|
||||
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
||||
val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.getOrDefault(emptyList()) }
|
||||
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
|
||||
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
|
||||
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
|
||||
|
||||
@@ -203,6 +203,8 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val artistName = artist.artistName ?: return@launch
|
||||
|
||||
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
|
||||
// Bewusst ohne id: artistId ist hier DEEZERS Kuenstler-ID, nicht Navidromes. Sie hier
|
||||
// mitzugeben wuerde Navidrome eine fremde ID unterschieben.
|
||||
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
|
||||
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
|
||||
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
|
||||
|
||||
Reference in new issue
Block a user