feat(web+android): gefolgte Künstler begrenzt anzeigen, mit "Alle anzeigen"
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
cb43c3375e
commit
86810b6a51
8 files changed
+112
-7
No files matched your search
+16
-1
@@ -79,7 +79,8 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
|||||||
|
|
||||||
## [2.7] — 2026-09-16
|
## [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
|
### Added
|
||||||
- **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu
|
- **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,
|
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-
|
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.
|
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
|
- **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
|
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
|
Playlists, Alben, Künstler und Offline. Die markierten IDs werden bei jedem Einschalten frisch
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.novasonic.app"
|
applicationId = "com.novasonic.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 16
|
versionCode = 17
|
||||||
versionName = "1.13"
|
versionName = "1.14"
|
||||||
// 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -418,6 +418,10 @@ fun AppNavigation() {
|
|||||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||||
onOpenNews = { navController.navigate("artist-news") },
|
onOpenNews = { navController.navigate("artist-news") },
|
||||||
|
onOpenFollowed = {
|
||||||
|
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = true
|
||||||
|
navController.navigate(TopLevel.LIBRARY.route)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("artist-news") {
|
composable("artist-news") {
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ fun ArtistNewsScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 10.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(
|
FilterChip(
|
||||||
selected = state.tage == tage,
|
selected = state.tage == tage,
|
||||||
onClick = { vm.laden(tage) },
|
onClick = { vm.laden(tage) },
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ fun LibraryScreen(
|
|||||||
val vm: LibraryViewModel = viewModel()
|
val vm: LibraryViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val offlineVm: OfflineViewModel = viewModel()
|
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()
|
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ import com.novasonic.app.ui.theme.*
|
|||||||
|
|
||||||
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
|
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
|
@Composable
|
||||||
fun StarredScreen(
|
fun StarredScreen(
|
||||||
coverUrl: (String?) -> String?,
|
coverUrl: (String?) -> String?,
|
||||||
@@ -41,6 +44,7 @@ fun StarredScreen(
|
|||||||
onArtistClick: (String) -> Unit = {},
|
onArtistClick: (String) -> Unit = {},
|
||||||
onAlbumClick: (String) -> Unit = {},
|
onAlbumClick: (String) -> Unit = {},
|
||||||
onOpenNews: () -> Unit = {},
|
onOpenNews: () -> Unit = {},
|
||||||
|
onOpenFollowed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val repo = context.app.repository
|
val repo = context.app.repository
|
||||||
@@ -146,9 +150,17 @@ fun StarredScreen(
|
|||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.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) }
|
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))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -213,3 +225,22 @@ private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onC
|
|||||||
color = NsOnSurface2, maxLines = 1)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-2
@@ -7,7 +7,7 @@ import { openEditPlModal } from '../ui/ui.js';
|
|||||||
// Circular — safe (called inside event handlers)
|
// Circular — safe (called inside event handlers)
|
||||||
import { navigate } from './navigation.js';
|
import { navigate } from './navigation.js';
|
||||||
import { quickPlayPlaylist } from './detail.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`
|
// 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)
|
// 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
|
// Kuenstler, denen man folgt. Die Sortierchips oben gelten fuer die Songliste; hier waere eine
|
||||||
// eigene Sortierung nur Ballast, also alphabetisch.
|
// 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) {
|
function _renderStarredArtists(artists) {
|
||||||
|
_starredArtists = artists;
|
||||||
const wrap = document.getElementById('starred-artists-section');
|
const wrap = document.getElementById('starred-artists-section');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
wrap.innerHTML = '';
|
wrap.innerHTML = '';
|
||||||
if (!artists.length) return;
|
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 s = mkSection(`Künstler, denen du folgst (${artists.length})`);
|
||||||
const row = document.createElement('div'); row.className = 'scroll-row';
|
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, '🎤');
|
const c = makeCard(dispArtistById(a.id, a.name), `${a.albumCount || 0} Alben`, null, '🎤');
|
||||||
c.classList.add('artist-card');
|
c.classList.add('artist-card');
|
||||||
c.onclick = () => navigate('artist-detail', { id: a.id, name: a.name });
|
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 if (a.coverArt) setzen(coverUrl(a.coverArt, 300)); // kein Extra-Aufruf noetig
|
||||||
else getArtistImage(a.id).then(setzen);
|
else getArtistImage(a.id).then(setzen);
|
||||||
});
|
});
|
||||||
|
if (sortiert.length > zeigen) {
|
||||||
|
const mehr = document.createElement('div');
|
||||||
|
mehr.className = 'card artist-card starred-more';
|
||||||
|
mehr.innerHTML = '<div class="card-art"><div class="no-art">→</div></div>'
|
||||||
|
+ '<div class="card-info"><div class="card-title">Alle anzeigen</div>'
|
||||||
|
+ `<div class="card-sub">+${sortiert.length - zeigen} weitere</div></div>`;
|
||||||
|
mehr.onclick = () => { navigate('artists'); loadArtists('followed'); };
|
||||||
|
row.appendChild(mehr);
|
||||||
|
}
|
||||||
s.appendChild(row); wrap.appendChild(s);
|
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) {
|
function _renderStarredAlbums(albums) {
|
||||||
const wrap = document.getElementById('starred-albums-section');
|
const wrap = document.getElementById('starred-albums-section');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
|
|||||||
Reference in new issue
Block a user