From 86810b6a51396a61c47e9a8757f9744e04c71545 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 18 Sep 2026 18:37:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(web+android):=20gefolgte=20K=C3=BCnstler?= =?UTF-8?q?=20begrenzt=20anzeigen,=20mit=20"Alle=20anzeigen"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher landeten alle gefolgten Künstler in der waagerechten Reihe der Favoriten — bei 200 Gefolgten 200 Karten samt Bildern, von denen man vier sieht. Am Ende steht jetzt "Alle anzeigen (+n weitere)" und springt in die Künstlerliste mit aktivem Gefolgt-Filter. Im Web richtet sich die Anzahl nach der Fensterbreite statt nach einer festen Zahl (passende Karten minus eine, damit der Knopf nicht angeschnitten klebt; Untergrenze zwei), und bei Größenänderung wird neu aufgeteilt. Android zeigt zehn; die LazyRow baut ohnehin nur Sichtbares, dort ging es allein um Durchsehbarkeit. LibraryIntent reicht die Absicht einmalig durch, weil das LibraryViewModel am Navigationseintrag hängt und einen Zielwechsel nicht überlebt. Dazu: die Zeitfenster-Chips der Android-Zeitleiste beginnen mit 90 Tagen, der Vorgabe — Web war schon so. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++++++++- android/app/build.gradle.kts | 4 +- .../com/novasonic/app/data/LibraryIntent.kt | 11 ++++++ .../com/novasonic/app/ui/AppNavigation.kt | 4 ++ .../app/ui/screen/ArtistNewsScreen.kt | 3 +- .../novasonic/app/ui/screen/LibraryScreen.kt | 9 +++++ .../novasonic/app/ui/screen/StarredScreen.kt | 33 +++++++++++++++- src/views/playlists.js | 38 ++++++++++++++++++- 8 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/data/LibraryIntent.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d4b3b..b1d344b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,8 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d ## [2.7] — 2026-09-16 -**Android:** 1.13 (versionCode 16) — Folgen, Zeitleiste und der Gefolgt-Filter. +**Android:** 1.13 (versionCode 16) → 1.14 (versionCode 17). 1.13 brachte Folgen, Zeitleiste und +den Gefolgt-Filter, 1.14 die begrenzte Reihe in den Favoriten. ### Added - **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu @@ -107,6 +108,20 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d kein `starred` mit, die Markierungen kommen deshalb aus `getStarred2` und werden 30 s gehalten, damit das Umschalten der Chips nicht jedes Mal nachfragt. Ein Herz-Klick verwirft diesen Zwischen- speicher sofort — sonst zeigte der Filter nach dem Entfolgen noch eine halbe Minute den alten Stand. +- **Die Reihe der gefolgten Künstler ist begrenzt, mit „Alle anzeigen" (Web + Android)** — vorher + landeten **alle** gefolgten Künstler in der waagerechten Reihe der Favoriten. Bei 200 Gefolgten + sind das 200 Karten samt Bildern, von denen man vier sieht — und zum Durchsehen taugt eine endlose + Reihe ohnehin nicht. Am Ende steht jetzt eine Karte „Alle anzeigen (+n weitere)", die in die + Künstlerliste mit aktivem Gefolgt-Filter springt. + - **Im Web richtet sich die Anzahl nach der Fensterbreite** statt nach einer festen Zahl: es wird + gerechnet, wie viele Karten nebeneinander passen (Kartenbreite plus Abstand), minus eine, damit + „Alle anzeigen" nicht angeschnitten am Rand klebt. Untergrenze zwei. Bei einer Größenänderung + wird neu aufgeteilt — sonst bliebe die Zahl auf dem Stand des ersten Aufbaus. + - Android zeigt zehn und dann den Knopf; die `LazyRow` baut ohnehin nur Sichtbares, dort ging es + allein um die Durchsehbarkeit. Weil das `LibraryViewModel` am Navigationseintrag hängt und einen + Zielwechsel nicht überlebt, reicht `LibraryIntent` die Absicht einmalig durch. +- **Zeitfenster-Chips der Zeitleiste beginnen mit 90 Tagen (Android)** — das ist die Vorgabe, also + steht der aktive Chip jetzt vorn statt in der Mitte. Web war schon so. - **Filter „❤ Gefolgt" im Künstler-Reiter (Android)** — zwei Chips über der Liste, gleiche Stelle wie im Web. Bewusst ein Filter und kein eigener Reiter: die Bibliotheksleiste trägt schon Playlists, Alben, Künstler und Offline. Die markierten IDs werden bei jedem Einschalten frisch diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index da38943..d7830cb 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 = 16 - versionName = "1.13" + versionCode = 17 + versionName = "1.14" // 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/data/LibraryIntent.kt b/android/app/src/main/kotlin/com/novasonic/app/data/LibraryIntent.kt new file mode 100644 index 0000000..5649978 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/data/LibraryIntent.kt @@ -0,0 +1,11 @@ +package com.novasonic.app.data + +/** + * Einmalige Absicht beim Öffnen der Bibliothek. Das LibraryViewModel hängt am Navigationseintrag, + * überlebt also keinen Wechsel des Ziels — ein Zustand, den ein anderer Screen setzen will, muss + * deshalb hier durch. Wird beim Verarbeiten sofort zurückgesetzt, damit er nicht nachwirkt. + */ +object LibraryIntent { + /** Direkt in den Künstler-Reiter mit aktivem "Gefolgt"-Filter springen. */ + var oeffneGefolgte: Boolean = false +} 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 0ec24a9..3d62a63 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 @@ -418,6 +418,10 @@ fun AppNavigation() { onArtistClick = { id -> navController.navigate("artist/$id") }, onAlbumClick = { id -> navController.navigate("album/$id") }, onOpenNews = { navController.navigate("artist-news") }, + onOpenFollowed = { + com.novasonic.app.data.LibraryIntent.oeffneGefolgte = true + navController.navigate(TopLevel.LIBRARY.route) + }, ) } composable("artist-news") { diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt index e276b65..c854e52 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt @@ -63,7 +63,8 @@ fun ArtistNewsScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 10.dp), ) { - listOf(30 to "30 Tage", 90 to "90 Tage", 365 to "1 Jahr").forEach { (tage, label) -> + // 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn. + listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) -> FilterChip( selected = state.tage == tage, onClick = { vm.laden(tage) }, diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index 84409bd..1e02ea0 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -64,6 +64,15 @@ fun LibraryScreen( val vm: LibraryViewModel = viewModel() val state by vm.state.collectAsStateWithLifecycle() val offlineVm: OfflineViewModel = viewModel() + + // Absicht aus den Favoriten ("Alle anzeigen" bei den gefolgten Künstlern) einmalig einlösen. + LaunchedEffect(Unit) { + if (com.novasonic.app.data.LibraryIntent.oeffneGefolgte) { + com.novasonic.app.data.LibraryIntent.oeffneGefolgte = false + vm.selectTab(LibraryTab.ARTISTS) + vm.nurGefolgteUmschalten(true) + } + } val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle() // Fully-downloaded source IDs per type — drives the "downloaded" badge on cards. 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 f7f3e7c..7f833f7 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 @@ -31,6 +31,9 @@ import com.novasonic.app.ui.theme.* enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST } +/** So viele gefolgte Kuenstler stehen in der Reihe, der Rest hinter "Alle anzeigen". */ +private const val GEZEIGT = 10 + @Composable fun StarredScreen( coverUrl: (String?) -> String?, @@ -41,6 +44,7 @@ fun StarredScreen( onArtistClick: (String) -> Unit = {}, onAlbumClick: (String) -> Unit = {}, onOpenNews: () -> Unit = {}, + onOpenFollowed: () -> Unit = {}, ) { val context = LocalContext.current val repo = context.app.repository @@ -146,9 +150,17 @@ fun StarredScreen( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - items(gefolgteKuenstler, key = { it.id }) { a -> + // Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist + // zum Durchsehen untauglich — der Rest gehoert in die gefilterte + // Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab. + items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a -> GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) } } + if (gefolgteKuenstler.size > GEZEIGT) { + item(key = "alle_kuenstler") { + AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed) + } + } } Spacer(Modifier.height(8.dp)) } @@ -213,3 +225,22 @@ private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onC color = NsOnSurface2, maxLines = 1) } } + +@Composable +private fun AlleAnzeigen(weitere: Int, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(110.dp).clickable(onClick = onClick), + ) { + Box( + Modifier.size(110.dp).clip(CircleShape).background(NsSurface2), + contentAlignment = Alignment.Center, + ) { + Text("→", fontSize = 26.sp, color = NsAccent) + } + Spacer(Modifier.height(6.dp)) + Text("Alle anzeigen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, + maxLines = 1, color = MaterialTheme.colorScheme.onSurface) + Text("+$weitere weitere", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2, maxLines = 1) + } +} diff --git a/src/views/playlists.js b/src/views/playlists.js index 3c96214..6ea352a 100644 --- a/src/views/playlists.js +++ b/src/views/playlists.js @@ -7,7 +7,7 @@ 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'; +import { getArtistImage, loadArtists } 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) @@ -251,14 +251,29 @@ export async function loadStarred(sort = _starredSort) { // Kuenstler, denen man folgt. Die Sortierchips oben gelten fuer die Songliste; hier waere eine // eigene Sortierung nur Ballast, also alphabetisch. +// Wie viele gezeigt werden, richtet sich nach der Fensterbreite: eine Reihe voll, nicht mehr. Bei +// 200 gefolgten Kuenstlern 200 Karten samt Bildern zu bauen, von denen man vier sieht, waere +// Verschwendung — den Rest erreicht man ueber "Alle anzeigen" (Kuenstlerseite mit Gefolgt-Filter). +const _KARTE = 145 + 12; // .scroll-row .card width + gap + +function _passendeAnzahl(wrap) { + const breite = wrap?.clientWidth || document.getElementById('main-content')?.clientWidth || 900; + // Eine Karte weniger, damit "Alle anzeigen" am Ende sichtbar ist statt angeschnitten. + return Math.max(2, Math.floor(breite / _KARTE) - 1); +} + +let _starredArtists = []; function _renderStarredArtists(artists) { + _starredArtists = artists; const wrap = document.getElementById('starred-artists-section'); if (!wrap) return; wrap.innerHTML = ''; if (!artists.length) return; + const sortiert = [...artists].sort((a,b) => (a.name||'').localeCompare(b.name||'')); + const zeigen = _passendeAnzahl(wrap); 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 => { + sortiert.slice(0, zeigen).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 }); @@ -273,9 +288,28 @@ function _renderStarredArtists(artists) { else if (a.coverArt) setzen(coverUrl(a.coverArt, 300)); // kein Extra-Aufruf noetig else getArtistImage(a.id).then(setzen); }); + if (sortiert.length > zeigen) { + const mehr = document.createElement('div'); + mehr.className = 'card artist-card starred-more'; + mehr.innerHTML = '
→
' + + '
Alle anzeigen
' + + `
+${sortiert.length - zeigen} weitere
`; + mehr.onclick = () => { navigate('artists'); loadArtists('followed'); }; + row.appendChild(mehr); + } s.appendChild(row); wrap.appendChild(s); } +// Bei Groessenaenderung neu aufteilen — sonst bleibt die Zahl auf dem Stand des ersten Aufbaus. +let _resizeTimer = null; +window.addEventListener('resize', () => { + if (!document.getElementById('page-starred')?.classList.contains('active')) return; + clearTimeout(_resizeTimer); + _resizeTimer = setTimeout(() => { + if (_starredArtists.length) _renderStarredArtists(_starredArtists); + }, 200); +}); + function _renderStarredAlbums(albums) { const wrap = document.getElementById('starred-albums-section'); if (!wrap) return;