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 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() }
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in new issue
Block a user