diff --git a/CHANGELOG.md b/CHANGELOG.md index e147188..664d325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an | Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ | | Teilen per Link | ✅ | ✅ | +| Album → Künstler (Deezer-Suche) | ✅ | ✅ | | Playlist aus Import anlegen | ✅ | ✅ | | Song von Festplatte löschen | ✅ | ✅ | | Metadata editor (tags + cover) | ✅ | ❌ | @@ -69,6 +70,33 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an --- +## [2.5] — 2026-09-10 + +### Added +- **Android 1.8 (versionCode 11)** — enthält den Sprung vom Album zum Künstler (siehe unten). +- **Vom Album zum Künstler springen (Web + Android)** — in der globalen Suche führte ein Album + bisher in eine Sackgasse: der Künstlername darunter war reiner Text. Wer von dort zum Künstler + wollte, musste zurück und neu suchen. Der Name ist jetzt anklickbar und öffnet dieselbe + Deezer-Künstlerseite wie eine Künstler-Kachel. Betrifft im Web die Inline-Albumansicht in der + globalen Suche und die Vollbild-Albumseite, in Android die Album-Detailansicht in Erkunden — + dort stand vorher **gar kein** Künstler, nur der Albumtitel. + - **Kostet keinen zusätzlichen API-Aufruf.** Deezer legt `/search/album` und `/album/{id}` das + vollständige `artist`-Objekt samt `id` bei (in 120 von 120 Treffern über zwölf Suchen geprüft); + beide Mappings (`_dzAlbum` im Web, `DzAlbum.toItunesItem()` in Android) übernahmen die id schon, + sie wurde nur nirgends benutzt. + - **Ausnahme mit Absicht:** `/artist/{id}/albums` lässt das `artist`-Objekt weg. Alben aus dieser + Liste haben keine id, dort bleibt der Name deshalb reiner Text statt ins Leere zu zeigen — man + kommt ja gerade von der Künstlerseite. Ein Ersatz-Lookup wäre ein Aufruf für einen Sprung, den + niemand macht. + - Deezer nennt nur den **Haupt**künstler: bei „A feat. B" landet man auf A. + - Android lädt Bild und Fanzahl nach, wenn der Künstler aus einem Album konstruiert wurde — sonst + sähe die Kopfzeile anders aus als beim Weg über die Künstlersuche. + - Nebenbei: `.album-artist-name` trug `cursor:pointer` unabhängig davon, ob der Name irgendwohin + führte — der Zeiger versprach auf der Deezer- und der Teil-Album-Seite einen Klick, den es nicht + gab. Jetzt nur noch dort, wo wirklich ein Ziel dahintersteht. + +--- + ## [2.4] — 2026-09-06 ### Added diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7096766..ec72995 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.novasonic.app" minSdk = 26 targetSdk = 35 - versionCode = 10 - versionName = "1.7" + versionCode = 11 + versionName = "1.8" // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // Platzhalter stehen und der App-Link greift schlicht nicht. diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt index 6df54be..1c3bcdc 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt @@ -126,6 +126,7 @@ fun ExploreScreen( previewingId = state.previewingId, onPreview = vm::togglePreview, onDownload = vm::showDownloadSheet, + onArtistClick = vm::openArtistOfAlbum, onBack = vm::dismissAlbum, ) } @@ -407,6 +408,7 @@ private fun GlobalExploreContent( previewingId = state.previewingId, onPreview = vm::togglePreview, onDownload = onDownload, + onArtistClick = vm::openArtistOfAlbum, onBack = vm::dismissAlbum, ) return @@ -524,6 +526,7 @@ internal fun ItunesAlbumDetail( previewingId: Long?, onPreview: (ItunesItem) -> Unit, onDownload: (ItunesItem) -> Unit = {}, + onArtistClick: ((ItunesItem) -> Unit)? = null, onBack: () -> Unit, ) { Column(Modifier.fillMaxSize()) { @@ -535,9 +538,25 @@ internal fun ItunesAlbumDetail( Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = MaterialTheme.colorScheme.onSurface) } - Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, - fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f)) + // Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums + // lässt das artist-Objekt weg, dort bleibt der Name einfach Text. + val artistName = albumItem?.artistName + val artistTippbar = onArtistClick != null && albumItem?.artistId != null && + !artistName.isNullOrBlank() + Column( + Modifier.weight(1f).then( + if (artistTippbar) Modifier.clickable { onArtistClick!!(albumItem!!) } else Modifier + ) + ) { + Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, + fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface) + if (!artistName.isNullOrBlank()) { + Text(artistName, fontFamily = Outfit, fontSize = 12.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis, + color = if (artistTippbar) NsAccent else NsOnSurface2) + } + } if (albumItem != null) { IconButton(onClick = { onDownload(albumItem) }) { Icon(Icons.Default.Download, "Album herunterladen", 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 9a46627..731e7bd 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 @@ -180,6 +180,16 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) { _state.value = _state.value.copy(albumTracks = null, albumItem = null) } + /** Vom geöffneten Album zu dessen Künstler springen. Deezer legt das artist-Objekt samt id + * jedem Albumtreffer bei, der Sprung kostet also keinen zusätzlichen Suchaufruf. Die + * Albumansicht wird geschlossen, sonst läge sie weiter über der Künstlerseite. */ + fun openArtistOfAlbum(album: ItunesItem) { + val id = album.artistId ?: return + val name = album.artistName ?: return + _state.value = _state.value.copy(albumTracks = null, albumItem = null, partialAlbum = null) + openArtistDetail(ItunesItem(wrapperType = "artist", artistId = id, artistName = name)) + } + fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) } fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) } @@ -199,9 +209,14 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) { val ndAlbums = ndAlbumsD.await() val iAlbums = iAlbumsD.await() + // Aus einem Album konstruierte Künstler tragen weder Bild noch Fanzahl — einmal + // nachschlagen, damit die Kopfzeile genauso aussieht wie aus der Künstlersuche. + val voll = if (artistId != null && artist.artworkUrl100 == null && artist.fanCount == null) + runCatching { ItunesClient.lookupArtist(artistId) }.getOrNull() ?: artist + else artist _state.value = _state.value.copy( artistDetail = ItunesArtistState( - artist = artist, + artist = voll, isLoading = false, ndAlbums = ndAlbums, iAlbums = iAlbums, diff --git a/src/views/detail.js b/src/views/detail.js index ed23f6f..df1c45d 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -482,7 +482,17 @@ export async function loadArtistAllSongs({ albums, artist }) { export async function loadDeezerAlbumDetail(album) { document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack(); document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—'; - document.getElementById('deezer-album-detail-artist').textContent = album.artistName || ''; + // Deezer liefert die Künstler-ID in jedem Albumtreffer gleich mit — ohne sie ist der Name nur + // toter Text (z. B. bei Alben aus /artist/{id}/albums, wo Deezer das artist-Objekt weglässt). + const dzArtistEl = document.getElementById('deezer-album-detail-artist'); + dzArtistEl.textContent = album.artistName || ''; + dzArtistEl.onclick = null; + dzArtistEl.classList.toggle('is-link', !!(album.artistId && album.artistName)); + if (album.artistId && album.artistName) { + dzArtistEl.onclick = () => navigate('artist-detail', { + id: null, name: album.artistName, deezerArtistId: album.artistId, deezerFanCount: null, + }); + } const cw = document.getElementById('deezer-album-cover-wrap'); const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600'); cw.innerHTML = art ? `` : '
💿
'; diff --git a/src/views/search.js b/src/views/search.js index 4d7bb23..5097021 100644 --- a/src/views/search.js +++ b/src/views/search.js @@ -206,7 +206,7 @@ export async function loadDeezerAlbum(album) {
${esc(album.collectionName)}
-
${esc(album.artistName)}
+
@@ -215,6 +215,12 @@ export async function loadDeezerAlbum(album) { tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks))); s.appendChild(list); el.innerHTML = ''; el.appendChild(s); + // Deezer hängt an jeden Albumtreffer das Artist-Objekt mit id — ein Sprung zum Künstler + // kostet also keinen zusätzlichen Aufruf. + if (album.artistId) { + s.querySelector('#dz-alb-artist')?.addEventListener('click', () => + loadDeezerArtistResults(album.artistId, album.artistName, null)); + } } catch(e) { toast('Fehler: ' + e.message); } } diff --git a/styles.css b/styles.css index 0dd76f4..9f3dcd4 100644 --- a/styles.css +++ b/styles.css @@ -274,8 +274,10 @@ button:focus{outline:none} .album-cover .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:50px} .album-type{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text2);margin-bottom:5px} .album-name{font-size:28px;font-weight:800;letter-spacing:-1px;margin-bottom:5px;line-height:1.1} -.album-artist-name{font-size:14px;color:var(--text2);margin-bottom:10px;cursor:pointer} -.album-artist-name:hover{color:var(--accent)} +.album-artist-name{font-size:14px;color:var(--text2);margin-bottom:10px} +/* Zeiger nur, wo der Name wirklich irgendwohin führt (lokal: .song-link-Spans, Deezer: .is-link). */ +.album-artist-name .song-link:hover,.album-artist-name.is-link:hover{color:var(--accent)} +.album-artist-name.is-link{cursor:pointer;transition:color .15s} .album-stats{font-size:12px;color:var(--text3)} .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}