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
@@ -39,6 +39,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
||||
| Playlist create/edit | ✅ | ✅ |
|
||||
| Playlist cover upload | ✅ | ✅ |
|
||||
| Besitzer geteilter Playlists | ✅ | ✅ |
|
||||
| Künstler folgen / Album merken | ✅ | ✅ |
|
||||
| Spotify / YouTube importer | ✅ | ✅ |
|
||||
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
||||
| 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
|
||||
|
||||
**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
|
||||
?: 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() }
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
<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-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>
|
||||
|
||||
+68
-2
@@ -1,6 +1,6 @@
|
||||
import { S } from '../state.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 { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.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);
|
||||
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 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 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.onclick = () => openEventsModal(events, artist.name);
|
||||
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);
|
||||
@@ -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) {
|
||||
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
|
||||
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
|
||||
@@ -604,6 +666,10 @@ export async function loadAlbumDetail(album) {
|
||||
const songs = r.album?.song || [];
|
||||
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.`;
|
||||
_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
|
||||
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
|
||||
|
||||
@@ -291,6 +291,9 @@ button:focus{outline:none}
|
||||
.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}
|
||||
.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: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}
|
||||
|
||||
Reference in new issue
Block a user