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:
marcandClaude Opus 5 committed 2026-09-16 17:51:53 +02:00
1 parent de674dbe3f
commit 5f043beb5d
11 files changed
+177 -11

No files matched your search

+31
View File
@@ -39,6 +39,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
| Playlist create/edit | ✅ | ✅ | | Playlist create/edit | ✅ | ✅ |
| Playlist cover upload | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ |
| Besitzer geteilter Playlists | ✅ | ✅ | | Besitzer geteilter Playlists | ✅ | ✅ |
| Künstler folgen / Album merken | ✅ | ✅ |
| Spotify / YouTube importer | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ |
| Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ |
| Wiedergabe fortsetzen | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ |
@@ -75,6 +76,36 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
--- ---
## [2.7] — 2026-09-16
**Android:** APK folgt, sobald das Folgen vollständig ist (Glocke + Benachrichtigungen fehlen noch).
### Added
- **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu
neuen Veröffentlichungen und Konzerten. „Folgen" ist **kein eigener Speicher**, sondern Navidromes
Stern auf dem Künstler: `star&id=<ndArtistId>`. Damit ist der Zustand sofort auf allen Geräten da,
überlebt Neuinstallationen und taucht sogar in Navidromes eigener Oberfläche auf.
- Möglich ist das, weil Navidrome `id`, `albumId` und `artistId` beim Markieren in **eine** Liste
wirft und gleich behandelt (`media_annotation.go`) — unser vorhandener Aufruf trug also ohne
Änderung. Den Zustand liefert `starred` aus `getArtist`/`getAlbum`.
- Web: „Folgen"/„Folge ich" in der Aktionsleiste der Künstlerseite, „Merken"/„Gemerkt" bei den
Albumaktionen. Android: eine Pille auf der Künstlerseite, ein Herz in der Albumleiste.
- Beide Plattformen schalten **optimistisch** um und fallen bei einem Fehlschlag zurück — Android
über den vorhandenen `StarState`, der ohnehin über IDs arbeitet und deshalb unverändert taugt.
- Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über
den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen.
### Changed
- **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei
`getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg
sucht mit `LIKE` und `Max: 1` (`core/external/provider.go`): bei zwei gleichnamigen Künstlern in
der Bibliothek gewann schlicht der erste Treffer. Wir schicken jetzt `id` **und** `artist` — ältere
Navidrome-Versionen ignorieren `id`, neuere fallen bei unbekannter ID von selbst auf den Namen
zurück. In Erkunden bleibt es bewusst beim Namen: die dortige `artistId` ist **Deezers**, nicht
Navidromes.
---
## [2.6] — 2026-09-12 ## [2.6] — 2026-09-12
**Android:** 1.9 (versionCode 12) → 1.12 (versionCode 15). 1.9 brachte den Besitzer geteilter **Android:** 1.9 (versionCode 12) → 1.12 (versionCode 15). 1.9 brachte den Besitzer geteilter
@@ -149,9 +149,16 @@ class SubsonicClient(
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2 get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
?: com.novasonic.app.api.model.ArtistInfo() ?: 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 * id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
?: emptyList() * 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> = suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
get("getGenres.view").genres?.genre ?: emptyList() 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 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 getGenres() = api().getGenres()
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre) suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId) 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download 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.PlayArrow
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -177,6 +180,25 @@ fun AlbumScreen(
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent) 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 && val isFullyDownloaded = downloadGroup != null &&
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks && downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
downloadGroup!!.totalTracks > 0 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Event 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.PlayArrow
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Shuffle
@@ -320,6 +323,18 @@ fun ArtistDetailScreen(
onClick = { vm.shuffleAll() }, 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 { item {
val eventCount = state.events.size val eventCount = state.events.size
val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte" val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte"
@@ -585,20 +600,26 @@ fun ArtistDetailScreen(
} }
@Composable @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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(NsSurface) .background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(20.dp)) .border(1.dp, if (hervorgehoben) NsAccent else NsBorder, RoundedCornerShape(20.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 9.dp), .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)) Spacer(Modifier.width(6.dp))
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, 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) { class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository 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 offlineRepo = app.app.offlineRepository
private val albumId: String? = savedState["albumId"] private val albumId: String? = savedState["albumId"]
@@ -63,6 +63,12 @@ data class ArtistUiState(
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository 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 player = app.app.playerController
private val preview = app.app.previewController private val preview = app.app.previewController
private val artistId: String? = savedState["artistId"] private val artistId: String? = savedState["artistId"]
@@ -101,7 +107,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
val artist = artistDef.await() val artist = artistDef.await()
val info = infoDef.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 // 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. // so the Deezer enrichment below uses the pinned artist, not a name-guess.
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() } 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 artistName = artist.artistName ?: return@launch
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) } 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 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 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() } val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
+1
View File
@@ -228,6 +228,7 @@
<div class="album-actions"> <div class="album-actions">
<button class="btn-play-all" id="btn-play-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button> <button class="btn-play-all" id="btn-play-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
<button class="btn-outline" id="btn-dl-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button> <button class="btn-outline" id="btn-dl-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button>
<button class="btn-outline" id="btn-star-album" title="Album markieren"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg><span>Merken</span></button>
</div> </div>
</div> </div>
</div> </div>
+68 -2
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js'; import { S } from '../state.js';
import { esc, toast, fmtCount } from '../utils.js'; import { esc, toast, fmtCount } from '../utils.js';
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js'; import { api, invalidateApiCache, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { openArtistPin } from '../ui/artistPin.js'; import { openArtistPin } from '../ui/artistPin.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js'; import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js'; import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
@@ -112,7 +112,11 @@ export async function loadArtistDetail(artist) {
: hasND ? getArtistImage(artist.id) : Promise.resolve(null); : hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null); const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null); const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null); // 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 einfach). Der Namensweg
// sucht mit LIKE und Max:1 — bei zwei gleichnamigen Kuenstlern in der Bibliothek gewinnt also der
// erste Treffer, und das war nicht zwingend der gemeinte.
const topP = hasND ? api('getTopSongs', `&id=${encodeURIComponent(artist.id)}&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5); const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
const eventsP = fetchEvents(artist.name); const eventsP = fetchEvents(artist.name);
@@ -331,6 +335,30 @@ export async function loadArtistDetail(artist) {
evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`; evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`;
evBtn.onclick = () => openEventsModal(events, artist.name); evBtn.onclick = () => openEventsModal(events, artist.name);
actionBar.appendChild(evBtn); actionBar.appendChild(evBtn);
// Folgen = Navidromes Stern auf dem Künstler. Kein eigener Speicher, sofort auf allen Geräten
// und in Navidromes eigener Oberfläche sichtbar.
if (hasND && artist.id) {
const folgeBtn = document.createElement('button');
folgeBtn.className = 'artist-action-btn';
actionBar.appendChild(folgeBtn);
_verdrahteMerken(
folgeBtn, artist.id, _ndArtist?.starred,
'Folge ich', 'Folgen',
`❤ Du folgst ${artist.name}`, `Folgst ${artist.name} nicht mehr`,
);
// Ohne Pinnung läuft die Deezer-Zuordnung über den Namen — bei gleichnamigen Künstlern
// bekäme man dann dauerhaft die Neuheiten des Falschen gemeldet.
if (!_pin && !_ndArtist?.starred) {
folgeBtn.addEventListener('click', () => {
setTimeout(() => {
if (folgeBtn.classList.contains('is-starred')) {
toast('Tipp: Über ⚙ im Kopfbereich den richtigen Deezer-Künstler festpinnen');
}
}, 900);
}, { once: true });
}
}
} }
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
@@ -479,6 +507,40 @@ export async function loadArtistAllSongs({ albums, artist }) {
}); });
} }
// ─── MARKIEREN (Künstler + Album) ───
// Navidrome wirft beim Markieren `id`, `albumId` und `artistId` in eine Liste und behandelt sie
// gleich (media_annotation.go) — derselbe Aufruf taugt also für Songs, Alben und Künstler. Der
// Zustand kommt aus `starred` in getArtist/getAlbum.
const _HERZ = (voll) => `<svg width="13" height="13" viewBox="0 0 24 24" fill="${voll ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;
/**
* Verdrahtet einen Knopf als Markieren-Umschalter. Optimistisch: der Knopf springt sofort um und
* kehrt zurück, wenn der Aufruf scheitert — sonst fühlt sich das Markieren träge an.
*/
function _verdrahteMerken(btn, id, markiert, textAn, textAus, meldungAn, meldungAus) {
if (!btn || !id) return;
let an = !!markiert;
const malen = () => {
const label = btn.querySelector('span');
btn.innerHTML = _HERZ(an) + `<span>${an ? textAn : textAus}</span>`;
btn.classList.toggle('is-starred', an);
void label;
};
malen();
btn.onclick = async () => {
const vorher = an;
an = !an; malen();
try {
await api(vorher ? 'unstar' : 'star', `&id=${encodeURIComponent(id)}`);
invalidateApiCache('getStarred2');
toast(an ? meldungAn : meldungAus);
} catch (e) {
an = vorher; malen();
toast('Fehler: ' + e.message);
}
};
}
export async function loadDeezerAlbumDetail(album) { export async function loadDeezerAlbumDetail(album) {
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack(); document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—'; document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
@@ -604,6 +666,10 @@ export async function loadAlbumDetail(album) {
const songs = r.album?.song || []; const songs = r.album?.song || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60); const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`; document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
_verdrahteMerken(
document.getElementById('btn-star-album'), album.id, r.album?.starred,
'Gemerkt', 'Merken', '❤ Album gemerkt', 'Aus den Favoriten entfernt',
);
// r.album has sortName (album sort tag) — use it; nav album may not have sortName // r.album has sortName (album sort tag) — use it; nav album may not have sortName
document.getElementById('album-detail-name').textContent = dispAlbumName(r.album); document.getElementById('album-detail-name').textContent = dispAlbumName(r.album);
// Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the // Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the
+3
View File
@@ -291,6 +291,9 @@ button:focus{outline:none}
.dz-inline-artist.song-link:hover{color:var(--accent);text-decoration-color:var(--accent)} .dz-inline-artist.song-link:hover{color:var(--accent);text-decoration-color:var(--accent)}
.playlist-detail-desc{font-size:13px;color:var(--text2);line-height:1.5;margin-top:6px;max-width:480px} .playlist-detail-desc{font-size:13px;color:var(--text2);line-height:1.5;margin-top:6px;max-width:480px}
.album-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap} .album-actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}
/* Markiert: gefuelltes Herz in Akzentfarbe — gilt fuer den Album-Knopf und den Folgen-Knopf
auf der Kuenstlerseite. */
.btn-outline.is-starred,.artist-action-btn.is-starred{color:var(--accent);border-color:var(--accent)}
.btn-play-all{display:flex;align-items:center;gap:7px;padding:10px 20px;border-radius:25px;background:var(--accent);color:#000;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:opacity .15s,transform .15s} .btn-play-all{display:flex;align-items:center;gap:7px;padding:10px 20px;border-radius:25px;background:var(--accent);color:#000;border:none;font-family:var(--font);font-size:13px;font-weight:700;cursor:pointer;transition:opacity .15s,transform .15s}
.btn-play-all:hover{opacity:.9;transform:scale(1.02)} .btn-play-all:hover{opacity:.9;transform:scale(1.02)}
.btn-outline{display:flex;align-items:center;gap:7px;padding:10px 18px;border-radius:25px;background:var(--surface);border:1px solid var(--border2);color:var(--text);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .15s} .btn-outline{display:flex;align-items:center;gap:7px;padding:10px 18px;border-radius:25px;background:var(--surface);border:1px solid var(--border2);color:var(--text);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}