feat(web+android): vom Album in der globalen Suche zum Künstler springen (2.5 / Android 1.8)

Der Künstlername unter einem Deezer-Album war reiner Text — von dort zum
Künstler ging nur über Zurück und neu suchen. Er ist jetzt anklickbar und
öffnet dieselbe Deezer-Künstlerseite wie eine Künstler-Kachel.

Kostet keinen zusätzlichen API-Aufruf: Deezer legt /search/album und
/album/{id} das artist-Objekt samt id bei (120/120 Treffer über zwölf
Suchen geprüft), beide Mappings übernahmen die id längst.

/artist/{id}/albums lässt das artist-Objekt weg — Alben von dort bleiben
bewusst reiner Text, statt ins Leere zu zeigen.

Android zeigte in der Album-Detailansicht bisher gar keinen Künstler und
lädt Bild + Fanzahl nach, wenn der Künstler aus einem Album stammt.

.album-artist-name trug cursor:pointer auch ohne Ziel — jetzt nur noch,
wo wirklich etwas dahintersteht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-10 22:22:37 +02:00
1 parent fca7cc79c1
commit ec75fdef7b
7 files changed
+90 -10

No files matched your search

+28
View File
@@ -39,6 +39,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ |
| Wiedergabe fortsetzen | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ |
| Teilen per Link | ✅ | ✅ | | Teilen per Link | ✅ | ✅ |
| Album → Künstler (Deezer-Suche) | ✅ | ✅ |
| Playlist aus Import anlegen | ✅ | ✅ | | Playlist aus Import anlegen | ✅ | ✅ |
| Song von Festplatte löschen | ✅ | ✅ | | Song von Festplatte löschen | ✅ | ✅ |
| Metadata editor (tags + cover) | ✅ | ❌ | | 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 ## [2.4] — 2026-09-06
### Added ### Added
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 10 versionCode = 11
versionName = "1.7" versionName = "1.8"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht. // Platzhalter stehen und der App-Link greift schlicht nicht.
@@ -126,6 +126,7 @@ fun ExploreScreen(
previewingId = state.previewingId, previewingId = state.previewingId,
onPreview = vm::togglePreview, onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet, onDownload = vm::showDownloadSheet,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum, onBack = vm::dismissAlbum,
) )
} }
@@ -407,6 +408,7 @@ private fun GlobalExploreContent(
previewingId = state.previewingId, previewingId = state.previewingId,
onPreview = vm::togglePreview, onPreview = vm::togglePreview,
onDownload = onDownload, onDownload = onDownload,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum, onBack = vm::dismissAlbum,
) )
return return
@@ -524,6 +526,7 @@ internal fun ItunesAlbumDetail(
previewingId: Long?, previewingId: Long?,
onPreview: (ItunesItem) -> Unit, onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit = {}, onDownload: (ItunesItem) -> Unit = {},
onArtistClick: ((ItunesItem) -> Unit)? = null,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
@@ -535,9 +538,25 @@ internal fun ItunesAlbumDetail(
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = MaterialTheme.colorScheme.onSurface) tint = MaterialTheme.colorScheme.onSurface)
} }
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, // Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, // lässt das artist-Objekt weg, dort bleibt der Name einfach Text.
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f)) 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) { if (albumItem != null) {
IconButton(onClick = { onDownload(albumItem) }) { IconButton(onClick = { onDownload(albumItem) }) {
Icon(Icons.Default.Download, "Album herunterladen", Icon(Icons.Default.Download, "Album herunterladen",
@@ -180,6 +180,16 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(albumTracks = null, albumItem = null) _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 openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) } fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
@@ -199,9 +209,14 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
val ndAlbums = ndAlbumsD.await() val ndAlbums = ndAlbumsD.await()
val iAlbums = iAlbumsD.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( _state.value = _state.value.copy(
artistDetail = ItunesArtistState( artistDetail = ItunesArtistState(
artist = artist, artist = voll,
isLoading = false, isLoading = false,
ndAlbums = ndAlbums, ndAlbums = ndAlbums,
iAlbums = iAlbums, iAlbums = iAlbums,
+11 -1
View File
@@ -482,7 +482,17 @@ export async function loadArtistAllSongs({ albums, artist }) {
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 || '—';
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 cw = document.getElementById('deezer-album-cover-wrap');
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600'); const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>'; cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
+7 -1
View File
@@ -206,7 +206,7 @@ export async function loadDeezerAlbum(album) {
<button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button> <button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div style="flex:1"> <div style="flex:1">
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div> <div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div> <div style="font-size:12px;color:var(--text2)"${album.artistId ? ' class="song-link" id="dz-alb-artist"' : ''}>${esc(album.artistName)}</div>
</div> </div>
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,deezerData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button> <button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,deezerData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button>
</div> </div>
@@ -215,6 +215,12 @@ export async function loadDeezerAlbum(album) {
tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks))); tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks)));
s.appendChild(list); s.appendChild(list);
el.innerHTML = ''; el.appendChild(s); 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); } } catch(e) { toast('Fehler: ' + e.message); }
} }
+4 -2
View File
@@ -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-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-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-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{font-size:14px;color:var(--text2);margin-bottom:10px}
.album-artist-name:hover{color:var(--accent)} /* 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)} .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} .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}