From 19f241e4937bea2d143593a7aae336d0c030864b Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 18 Sep 2026 13:11:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(web+android):=20Folgen-Herz=20ins=20K?= =?UTF-8?q?=C3=BCnstler-Banner,=20Favoriten=20zeigen=20K=C3=BCnstler=20und?= =?UTF-8?q?=20Alben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Herz sitzt jetzt im Banner neben dem Pin-Zahnrad statt in der Aktionsleiste: die Leiste ist "was ich jetzt tue" (abspielen, mischen, Konzerte), Folgen ist ein Zustand und gehört an den Künstler. Auf beiden Plattformen gleich. Beim Album bleibt das Herz in der Aktionsleiste, dort gibt es kein Banner. Dazu die Übersicht, wem man folgt: die Favoritenseite bekommt Abschnitte Künstler / Alben / Songs. Ohne zusätzlichen Aufruf — getStarred2 liefert Künstler und Alben im selben Paket wie die Songs, wir haben sie bisher verworfen. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 9 ++- .../com/novasonic/app/ui/AppNavigation.kt | 2 + .../app/ui/screen/ArtistDetailScreen.kt | 44 +++++----- .../novasonic/app/ui/screen/StarredScreen.kt | 81 ++++++++++++++++++- index.html | 3 + src/views/detail.js | 56 +++++++------ src/views/playlists.js | 48 ++++++++++- styles.css | 1 + 8 files changed, 190 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a49520..c33e45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,8 +88,13 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d - 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. + - Das Herz sitzt **im Banner** des Künstlers, direkt neben dem ⚙ für den Pin — auf beiden + Plattformen gleich. Die Aktionsleiste darunter ist „was ich jetzt tue" (abspielen, mischen, + Konzerte); Folgen ist dagegen ein Zustand und gehört an den Künstler selbst. Beim Album bleibt + das Herz in der Aktionsleiste, dort gibt es kein Banner. + - **Übersicht, wem man folgt:** die Favoritenseite zeigt jetzt Abschnitte *Künstler, denen du + folgst*, *Gemerkte Alben* und *Songs*. Das kostet **keinen zusätzlichen Aufruf** — `getStarred2` + lieferte Künstler und Alben die ganze Zeit im selben Paket mit, wir haben sie nur weggeworfen. - 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 diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 9742c8e..0535995 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -415,6 +415,8 @@ fun AppNavigation() { onPlay = { songs, idx -> player.playSongs(songs, idx) }, onStarToggle = { song -> starToggle(song.id, song.starred != null) }, onShowContextMenu = ::showContextMenu, + onArtistClick = { id -> navController.navigate("artist/$id") }, + onAlbumClick = { id -> navController.navigate("album/$id") }, ) } composable(TopLevel.ABS.route) { 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 ce9da2b..1c83238 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 @@ -263,6 +263,24 @@ fun ArtistDetailScreen( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) + // Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste darunter: die Leiste + // ist "was ich jetzt tue", Folgen ist ein Zustand. Deshalb als Herz ins Banner, + // direkt neben das Zahnrad — genau wie im Web. + val overrides by StarState.overrides.collectAsStateWithLifecycle() + val folgt = overrides[artist.id] ?: (artist.starred != null) + Surface( + onClick = { vm.folgenUmschalten(artist.id, artist.starred != null) }, + shape = RoundedCornerShape(999.dp), + color = Color(0x59000000), + modifier = Modifier.align(Alignment.TopEnd).padding(top = 10.dp, end = 52.dp), + ) { + Icon( + if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, + if (folgt) "Du folgst ${artist.name}" else "${artist.name} folgen", + tint = if (folgt) NsAccent else Color.White.copy(0.72f), + modifier = Modifier.padding(7.dp).size(17.dp), + ) + } // "Wrong artist?" pin — minimal icon-only chip, top-right over the hero. Surface( onClick = { vm.openPinPicker() }, @@ -323,18 +341,6 @@ 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" @@ -600,26 +606,20 @@ fun ArtistDetailScreen( } @Composable -private fun ArtistActionPill( - icon: ImageVector, - label: String, - hervorgehoben: Boolean = false, - onClick: () -> Unit, -) { - val farbe = if (hervorgehoben) NsAccent else NsOnSurface +private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clip(RoundedCornerShape(20.dp)) .background(NsSurface) - .border(1.dp, if (hervorgehoben) NsAccent else NsBorder, RoundedCornerShape(20.dp)) + .border(1.dp, NsBorder, RoundedCornerShape(20.dp)) .clickable(onClick = onClick) .padding(horizontal = 18.dp, vertical = 9.dp), ) { - Icon(icon, null, tint = farbe, modifier = Modifier.size(16.dp)) + Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(6.dp)) Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, color = farbe) + fontSize = 13.sp, color = NsOnSurface) } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt index f60ea60..f3214b0 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt @@ -1,8 +1,18 @@ package com.novasonic.app.ui.screen import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import coil.compose.AsyncImage +import com.novasonic.app.ui.component.AlbumCard import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -28,18 +38,27 @@ fun StarredScreen( onPlay: (List, Int) -> Unit, onStarToggle: (Song) -> Unit, onShowContextMenu: ((Song) -> Unit)? = null, + onArtistClick: (String) -> Unit = {}, + onAlbumClick: (String) -> Unit = {}, ) { val context = LocalContext.current val repo = context.app.repository var songs by remember { mutableStateOf>(emptyList()) } + // getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — die Uebersicht + // "wem folge ich" kostet also keinen zusaetzlichen Aufruf. + var gefolgteKuenstler by remember { mutableStateOf>(emptyList()) } + var gemerkteAlben by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } var sort by remember { mutableStateOf(StarredSort.NEWEST) } LaunchedEffect(Unit) { try { - songs = repo.getStarred().song + val markiert = repo.getStarred() + songs = markiert.song + gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() } + gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" } loading = false } catch (e: Exception) { error = e.message @@ -100,12 +119,43 @@ fun StarredScreen( Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Text(error!!, color = MaterialTheme.colorScheme.error) } - } else if (sorted.isEmpty()) { + } else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) { Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Text("Noch keine Favoriten", color = NsOnSurface2) } } else { LazyColumn { + if (gefolgteKuenstler.isNotEmpty()) { + item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(gefolgteKuenstler, key = { it.id }) { a -> + GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) } + } + } + Spacer(Modifier.height(8.dp)) + } + } + if (gemerkteAlben.isNotEmpty()) { + item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(gemerkteAlben, key = { it.id }) { al -> + AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) } + } + } + Spacer(Modifier.height(8.dp)) + } + } + if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) { + item { FavoritenLabel("Songs (${sorted.size})") } + } itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song -> SongRow( song = song, @@ -122,3 +172,30 @@ fun StarredScreen( } } } + +@Composable +private fun FavoritenLabel(titel: String) { + Text( + titel, + fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, + color = NsOnSurface2, + modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp), + ) +} + +@Composable +private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) { + Column(Modifier.width(110.dp).clickable(onClick = onClick)) { + AsyncImage( + model = bild, contentDescription = name, + contentScale = ContentScale.Crop, + modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2), + ) + Spacer(Modifier.height(6.dp)) + Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface) + Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp, + color = NsOnSurface2, maxLines = 1) + } +} diff --git a/index.html b/index.html index b7b52b5..35346eb 100644 --- a/index.html +++ b/index.html @@ -266,6 +266,9 @@ +
+
+
diff --git a/src/views/detail.js b/src/views/detail.js index 3f94e51..adfc154 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -105,6 +105,15 @@ export async function loadArtistDetail(artist) { pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`; pinBtn.onclick = () => openArtistPin(artist.id, artist.name); hero.appendChild(pinBtn); + + // Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste: die Leiste ist "was ich jetzt + // tue" (abspielen, mischen), Folgen ist ein Zustand. Deshalb als Herz ins Banner, neben das + // Zahnrad. Verdrahtet wird es weiter unten, sobald `starred` aus getArtist da ist. + hero.querySelector('.artist-follow-btn')?.remove(); + const folgeBtn = document.createElement('button'); + folgeBtn.className = 'artist-follow-btn'; + folgeBtn.style.cssText = 'position:absolute;top:10px;right:46px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:rgba(255,255,255,.72);cursor:pointer;backdrop-filter:blur(4px)'; + hero.appendChild(folgeBtn); } // Fire all requests immediately so they run in parallel. @@ -126,6 +135,24 @@ export async function loadArtistDetail(artist) { const [albumRes] = await Promise.allSettled([albumP]); const _ndArtist = albumRes.status === 'fulfilled' ? albumRes.value?.artist : null; + if (hasND && artist.id) { + const folgeBtn = hero?.querySelector('.artist-follow-btn'); + _verdrahteMerken( + folgeBtn, artist.id, _ndArtist?.starred, '', '', + `Du folgst ${artist.name}`, `${artist.name} folgen`, + ); + // Ohne Pinnung laeuft die Deezer-Zuordnung ueber den Namen — bei gleichnamigen Kuenstlern + // kaemen dauerhaft die Neuheiten des Falschen. + if (folgeBtn && !_pin && !_ndArtist?.starred) { + folgeBtn.addEventListener('click', () => { + setTimeout(() => { + if (folgeBtn.classList.contains('is-starred')) { + toast('Tipp: Über ⚙ daneben den richtigen Deezer-Künstler festpinnen'); + } + }, 900); + }, { once: true }); + } + } if (_ndArtist) cacheArtistSortNames([_ndArtist]); const _firstAlbums = _ndArtist?.album || []; const _bgCover = _firstAlbums[0] ? coverUrl(_firstAlbums[0].coverArt, 512) : null; @@ -336,29 +363,6 @@ export async function loadArtistDetail(artist) { 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); @@ -521,10 +525,10 @@ function _verdrahteMerken(btn, id, markiert, textAn, textAus, meldungAn, meldung if (!btn || !id) return; let an = !!markiert; const malen = () => { - const label = btn.querySelector('span'); - btn.innerHTML = _HERZ(an) + `${an ? textAn : textAus}`; + const txt = an ? textAn : textAus; + btn.innerHTML = _HERZ(an) + (txt ? `${txt}` : ''); btn.classList.toggle('is-starred', an); - void label; + if (!txt) btn.title = an ? meldungAn : meldungAus; }; malen(); btn.onclick = async () => { diff --git a/src/views/playlists.js b/src/views/playlists.js index b8cf454..a1159ab 100644 --- a/src/views/playlists.js +++ b/src/views/playlists.js @@ -1,12 +1,13 @@ import { S } from '../state.js'; import { esc, toast } from '../utils.js'; -import { api, apiCached, apiIsCached, coverUrl, romajiLookup } from '../api.js'; -import { makeCard, makeSongItem } from '../ui/components.js'; +import { api, apiCached, apiIsCached, coverUrl, romajiLookup, dispArtistById, getArtistOverride } from '../api.js'; +import { makeCard, makeAlbumCard, makeSongItem, mkSection } from '../ui/components.js'; import { playQueue } from '../player/player.js'; import { openEditPlModal } from '../ui/ui.js'; // Circular — safe (called inside event handlers) import { navigate } from './navigation.js'; import { quickPlayPlaylist } from './detail.js'; +import { getArtistImage } from './home.js'; // Besitzer einer Playlist, sofern es nicht die eigene ist. Navidrome liefert `owner` und `public` // in getPlaylists/getPlaylist mit; interessant ist die Angabe nur bei geteilten (oeffentlichen) @@ -230,6 +231,10 @@ export async function loadStarred(sort = _starredSort) { if (searchEl) searchEl.value = ''; try { const r = await api('getStarred2'); + // getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — hier ist also die + // Uebersicht "wem folge ich" zu haben, ohne einen zusaetzlichen Aufruf. + _renderStarredArtists(r.starred2?.artist || []); + _renderStarredAlbums(r.starred2?.album || []); let songs = (r.starred2?.song || []).map(s => ({...s, _starred: true})); if (sort === 'newest') songs.sort((a, b) => new Date(b.starred||0) - new Date(a.starred||0)); else if (sort === 'oldest') songs.sort((a, b) => new Date(a.starred||0) - new Date(b.starred||0)); @@ -238,10 +243,49 @@ export async function loadStarred(sort = _starredSort) { else if (sort === 'plays') songs.sort((a, b) => (b.playCount||0) - (a.playCount||0)); el.innerHTML = ''; if(!songs.length) { el.innerHTML = '
⭐

Noch keine Favoriten

'; return; } + const titel = document.getElementById('starred-songs-title'); + if (titel) titel.style.display = songs.length ? '' : 'none'; songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs, { noArtistLink: true }))); } catch(e) { el.innerHTML = `

${e.message}

`; } } +// Kuenstler, denen man folgt. Die Sortierchips oben gelten fuer die Songliste; hier waere eine +// eigene Sortierung nur Ballast, also alphabetisch. +function _renderStarredArtists(artists) { + const wrap = document.getElementById('starred-artists-section'); + if (!wrap) return; + wrap.innerHTML = ''; + if (!artists.length) return; + const s = mkSection(`Künstler, denen du folgst (${artists.length})`); + const row = document.createElement('div'); row.className = 'scroll-row'; + [...artists].sort((a,b) => (a.name||'').localeCompare(b.name||'')).forEach(a => { + const c = makeCard(dispArtistById(a.id, a.name), `${a.albumCount || 0} Alben`, null, '🎤'); + c.classList.add('artist-card'); + c.onclick = () => navigate('artist-detail', { id: a.id, name: a.name }); + row.appendChild(c); + const pin = getArtistOverride(a.id); + const setzen = img => { + if (!img) return; + const art = c.querySelector('.card-art'); + if (art) art.innerHTML = ``; + }; + if (pin?.image) setzen(pin.image); else getArtistImage(a.id).then(setzen); + }); + s.appendChild(row); wrap.appendChild(s); +} + +function _renderStarredAlbums(albums) { + const wrap = document.getElementById('starred-albums-section'); + if (!wrap) return; + wrap.innerHTML = ''; + if (!albums.length) return; + const s = mkSection(`Gemerkte Alben (${albums.length})`); + const row = document.createElement('div'); row.className = 'scroll-row'; + [...albums].sort((a,b) => new Date(b.starred||0) - new Date(a.starred||0)) + .forEach(a => row.appendChild(makeAlbumCard(a))); + s.appendChild(row); wrap.appendChild(s); +} + export function filterStarred(val) { const q = val.trim().toLowerCase(); document.querySelectorAll('#starred-songs .song-item').forEach(item => { diff --git a/styles.css b/styles.css index 09d59b0..bb03614 100644 --- a/styles.css +++ b/styles.css @@ -294,6 +294,7 @@ button:focus{outline:none} /* 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)} +.artist-follow-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}