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:
1 parent
fca7cc79c1
commit
ec75fdef7b
7 files changed
+88
-8
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
// 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,
|
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
|
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
@@ -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
@@ -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,'"')}})">⬇ 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,'"')}})">⬇ 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
@@ -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}
|
||||||
|
|||||||
Reference in new issue
Block a user