diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e0c12..5a49520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=`. 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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt index 7157875..d4c318f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/SubsonicClient.kt @@ -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 = - 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 = + 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 = get("getGenres.view").genres?.genre ?: emptyList() diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index b9217ee..d2fd9fd 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt index bdd0989..dcd0dd5 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt index 02ad0b4..ce9da2b 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt index 84bfd2b..2777db6 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/AlbumViewModel.kt @@ -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"] diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt index e4151c1..befe128 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt @@ -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() } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt index 731e7bd..fe5025d 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt @@ -203,6 +203,8 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) { val artistName = artist.artistName ?: return@launch val ndAlbumsD: Deferred> = 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> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) } val iAlbumsD: Deferred> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() } val iTracksD: Deferred> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() } diff --git a/index.html b/index.html index f422a1c..b7b52b5 100644 --- a/index.html +++ b/index.html @@ -228,6 +228,7 @@
+
diff --git a/src/views/detail.js b/src/views/detail.js index df1c45d..3f94e51 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -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 = ` Konzerte${events.length ? ` (${events.length})` : ''}`; 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) => ``; + +/** + * 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) + `${an ? textAn : textAus}`; + 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 diff --git a/styles.css b/styles.css index 350d131..09d59b0 100644 --- a/styles.css +++ b/styles.css @@ -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}