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}