fix(android): Now-Playing an Fold und Flip anpassen — Deckel, Hoehenmass, Rollbereich

Die Seite hatte keine Breitengrenze: das Cover war fillMaxWidth() mal aspectRatio(1f).
Aufgeklappt sind das ueber 800 dp, womit Titel, Fortschritt und Knoepfe aus dem Bild
geschoben wurden. Das Cover nimmt jetzt das Kleinste aus Breite, Hoehe abzueglich
Restbedarf und 400 dp; Titelblock/Fortschritt/Knoepfe sind auf 480 dp gedeckelt (wie
.np-inner im Web), Liedtext und Warteschlange auf 560 dp.

Auf kurzen Anzeigen (Flip-Frontdisplay, Querformat, kleine Handys) wurde die Knopfreihe
abgeschnitten. Jetzt schrumpfen dort Abstaende, Reiter und Abspielknopf, und die
Player-Seite laesst sich rollen, wenn es weiterhin nicht reicht: der Inhalt ist
mindestens so hoch wie die Seite, passt er, wird zentriert, passt er nicht, waechst er
darueber hinaus. Die Wischgeste zum Schliessen bleibt, weil ein Rollbereich ohne Weg die
Geste nicht verbraucht.

Entschieden wird nach Maszen, nicht nach Gerätenamen: schmal < 400 dp, kurz < 560 dp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-10-13 18:47:03 +02:00
1 parent b6e98463ee
commit 624832959d
3 files changed
+123 -26

No files matched your search

+30
View File
@@ -79,6 +79,36 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
--- ---
## [3.3] — 2026-10-13
**Android:** 1.26 (versionCode 29) → 1.27 (versionCode 30).
### Fixed
- **Now-Playing passte sich der Anzeige nicht an (Android)** — gemeldet auf einem aufgeklappten
Fold: die Ansicht wirkte überdimensional. Ursache war, dass die Seite überhaupt keine
Breitengrenze hatte: das Cover war `fillMaxWidth()` mal `aspectRatio(1f)`. Auf einem Handy
sind das knappe 360 dp und passt; aufgeklappt sind es über 800 dp, und ein quadratisches Bild
dieser Größe schob Titel, Fortschrittsbalken und Knopfreihe aus dem Bild. Die Web-Ansicht
deckelt dasselbe seit immer bei 480 px (`.np-inner`), Android jetzt auch.
- **Das Cover nimmt das Kleinste aus drei Maßen:** verfügbare Breite, Höhe abzüglich dessen was
der Rest der Seite braucht, und 400 dp als Obergrenze. Auf einem hohen Handy gewinnt wie
bisher die Breite — dort ändert sich nichts.
- **Breitengrenzen** für Titelblock, Fortschritt und Knöpfe (480 dp) sowie für Liedtext und
Warteschlange (560 dp). Ohne sie standen Schließen-Knopf und Menü ganz außen und die Reiter
einsam in der Mitte, und eine Textzeile lief über die ganze Tischbreite.
- **Höhe statt Breite als Maß.** Bei einer fast quadratischen Anzeige sagt die Breite nichts
mehr darüber, ob ein quadratisches Bild hineinpasst.
- **Kurze Anzeigen: Knopfreihe war abgeschnitten (Android)** — auf dem Frontdisplay eines Flip,
im Querformat und auf kleinen Handys reichte die Höhe nicht, und die Spalte wurde einfach
beschnitten. Jetzt schrumpfen auf kurzen Anzeigen Abstände, Reiter und Abspielknopf, und wenn
es dann noch nicht reicht, **lässt sich die Player-Seite rollen**. Beides in einem Aufbau: der
Inhalt ist mindestens so hoch wie die Seite — passt er, wird er zentriert; passt er nicht,
wächst er darüber hinaus und der Rollbereich hat etwas zu rollen. Die Wischgeste zum Schließen
bleibt erhalten, weil ein Rollbereich ohne Weg die Geste nicht verbraucht.
- Entschieden wird nach **Maßen, nicht nach Gerätenamen**: `schmal` unter 400 dp Breite,
`kurz` unter 560 dp Höhe. Damit greift es auf Fold zugeklappt, Flip-Frontdisplay, Tablet,
Querformat und geteiltem Bildschirm gleichermaßen.
## [3.2] — 2026-10-01 ## [3.2] — 2026-10-01
**Android:** 1.25 (versionCode 28) → 1.26 (versionCode 29). **Android:** 1.25 (versionCode 28) → 1.26 (versionCode 29).
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 29 versionCode = 30
versionName = "1.26" versionName = "1.27"
// 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.
@@ -68,6 +68,27 @@ import androidx.compose.ui.input.pointer.positionChange
private enum class NpTab { PLAYER, LYRICS, QUEUE } private enum class NpTab { PLAYER, LYRICS, QUEUE }
// ── Obergrenzen fuer grosse Anzeigen (Fold aufgeklappt, Tablet) ──────────────────────────────
// Bis 1.26 hatte diese Ansicht keine Breitengrenze: das Cover war `fillMaxWidth` mal
// `aspectRatio(1f)`. Auf einem Handy sind das knappe 360 dp und passt; auf dem inneren Display
// eines Fold sind es ueber 800 dp — ein quadratisches Cover dieser Groesse schob Titel,
// Fortschrittsbalken und Knoepfe aus dem Bild, und die ganze Seite wirkte ueberdimensional.
// Die Web-Ansicht deckelt dasselbe seit immer bei 480 px (`.np-inner { max-width: 480px }`).
// Zusaetzlich wird das Cover gegen die HOEHE gedeckelt: bei einem fast quadratischen Display ist
// die Breite kein gutes Mass mehr fuer ein quadratisches Bild.
private val NP_SPALTE_MAX = 480.dp // Player-Seite: Cover, Titel, Fortschritt, Knoepfe
private val NP_LISTE_MAX = 560.dp // Liedtext und Warteschlange — Listen duerfen etwas breiter
private val NP_COVER_MAX = 400.dp
// Was Titel, Album, Kuenstler, Fortschrittsbalken, Zeiten, Knopfreihe und die Abstaende
// dazwischen zusammen brauchen (gemessen an den gesetzten Groessen, mit etwas Luft). Das Cover
// bekommt den Rest — so passt die Seite auch auf kurze Anzeigen, wo bisher die Knopfreihe
// unten abgeschnitten wurde.
private val NP_RESTBEDARF = 260.dp
// Auf kurzen Anzeigen (Flip-Frontdisplay, Querformat) schrumpfen Abstaende und Abspielknopf
// zusaetzlich, der Rest braucht dann weniger — und wenn es trotzdem nicht reicht, laesst sich
// die Seite rollen.
private val NP_RESTBEDARF_KURZ = 200.dp
@Composable @Composable
fun NowPlayingScreen( fun NowPlayingScreen(
state: PlayerState, state: PlayerState,
@@ -167,11 +188,18 @@ fun NowPlayingScreen(
// Light scrim for readability // Light scrim for readability
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f))) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.15f)))
BoxWithConstraints(Modifier.fillMaxSize()) {
// Zwei Groessenfragen entscheiden das Layout, nicht ein Geraetename:
// schmal — passt die Kopfzeile mit drei Reitern noch in voller Groesse? (Flip-Deckel)
// kurz — bleibt nach Kopfzeile und Raendern ueberhaupt Platz? (Flip-Deckel, Querformat)
val schmal = maxWidth < 400.dp
val kurz = maxHeight < 560.dp
val obenPad = if (kurz) maxOf(statusBarTop + 4.dp, 20.dp) else topPad
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = topPad, bottom = maxOf(navBarBottom, 20.dp)) .padding(top = obenPad, bottom = maxOf(navBarBottom, if (kurz) 8.dp else 20.dp))
.pointerInput(Unit) { .pointerInput(Unit) {
var dragY = 0f var dragY = 0f
detectVerticalDragGestures( detectVerticalDragGestures(
@@ -185,9 +213,13 @@ fun NowPlayingScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier
.padding(horizontal = 20.dp) // Ohne Grenze stehen Schliessen-Knopf und Menue auf einem breiten Display
.padding(bottom = 28.dp), // ganz aussen und die Reiter einsam in der Mitte.
.widthIn(max = NP_LISTE_MAX)
.fillMaxWidth()
.padding(horizontal = if (schmal) 8.dp else 20.dp)
.padding(bottom = if (kurz) 10.dp else 28.dp),
) { ) {
NpCircleBtn(onClick = onDismiss) { NpCircleBtn(onClick = onDismiss) {
Icon(Icons.Default.KeyboardArrowDown, "Schließen", Icon(Icons.Default.KeyboardArrowDown, "Schließen",
@@ -209,7 +241,10 @@ fun NowPlayingScreen(
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(if (sel) Color.White.copy(0.18f) else Color.Transparent) .background(if (sel) Color.White.copy(0.18f) else Color.Transparent)
.clickable { tab = t } .clickable { tab = t }
.padding(horizontal = 18.dp, vertical = 9.dp), .padding(
horizontal = if (schmal) 9.dp else 18.dp,
vertical = if (kurz) 6.dp else 9.dp,
),
) { ) {
Text( Text(
when (t) { when (t) {
@@ -217,7 +252,8 @@ fun NowPlayingScreen(
NpTab.LYRICS -> "Lyrics" NpTab.LYRICS -> "Lyrics"
NpTab.QUEUE -> "Queue" NpTab.QUEUE -> "Queue"
}, },
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = if (schmal) 11.sp else 13.sp,
color = if (sel) Color.White else Color.White.copy(alpha = 0.4f), color = if (sel) Color.White else Color.White.copy(alpha = 0.4f),
) )
} }
@@ -265,20 +301,43 @@ fun NowPlayingScreen(
) { ) {
when (NpTab.entries[page]) { when (NpTab.entries[page]) {
// ── PLAYER TAB ───────────────────────────────────────────────── // ── PLAYER TAB ─────────────────────────────────────────────────
NpTab.PLAYER -> { NpTab.PLAYER -> BoxWithConstraints(Modifier.fillMaxSize()) {
var dragX by remember { mutableStateOf(0f) } var dragX by remember { mutableStateOf(0f) }
// Das Cover nimmt das KLEINSTE aus drei Maszen: verfuegbare Breite (minus der
// bisherigen 24 dp Rand je Seite), die Hoehe abzueglich dessen, was der Rest der
// Seite braucht, und eine feste Obergrenze. Auf einem hohen Handy gewinnt die
// Breite — dort bleibt alles wie bisher. Aufgeklappt greifen die anderen beiden.
val coverKante = minOf(
maxWidth - 48.dp,
(maxHeight - if (kurz) NP_RESTBEDARF_KURZ else NP_RESTBEDARF).coerceAtLeast(120.dp),
NP_COVER_MAX,
)
val spalte = minOf(maxWidth, NP_SPALTE_MAX)
// Vor dem Rollbereich festhalten: innerhalb des Box-Lambdas ist `maxHeight` des
// aeusseren Messrahmens nicht mehr erreichbar (Compose sperrt gleich markierte
// Layout-Bereiche gegeneinander ab).
val seitenHoehe = maxHeight
Spacer(Modifier.height(28.dp)) // Passt es, wird zentriert; passt es nicht, wird gescrollt. Beides mit einem
// Aufbau: der Inhalt ist mindestens so hoch wie die Seite (dann greift
// Cover art — full content width, scale-animates like Apple Music // Arrangement.Center), darf aber darueber hinauswachsen — dann hat der
// Rollbereich etwas zu rollen. Auf dem Frontdisplay eines Flip bleibt sonst
// nichts uebrig, was ohne Abschneiden darstellbar waere.
// Die Wischgeste zum Schliessen bleibt erhalten: ein Rollbereich ohne Weg
// verbraucht die Geste nicht, sie erreicht also weiter die Seite darunter.
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().heightIn(min = seitenHoehe),
) {
// Cover art — gedeckelt, scale-animates like Apple Music
AsyncImage( AsyncImage(
model = coverUrl, model = coverUrl,
contentDescription = song?.title, contentDescription = song?.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.padding(horizontal = 24.dp) .size(coverKante)
.fillMaxWidth()
.aspectRatio(1f)
.graphicsLayer { scaleX = coverScale; scaleY = coverScale } .graphicsLayer { scaleX = coverScale; scaleY = coverScale }
.shadow(40.dp, RoundedCornerShape(20.dp)) .shadow(40.dp, RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
@@ -296,10 +355,10 @@ fun NowPlayingScreen(
}, },
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(if (kurz) 12.dp else 24.dp))
// ── Song info ────────────────────────────────────────────── // ── Song info ──────────────────────────────────────────────
Column(Modifier.fillMaxWidth().padding(horizontal = 28.dp)) { Column(Modifier.width(spalte).padding(horizontal = 28.dp)) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -388,10 +447,10 @@ fun NowPlayingScreen(
} }
} }
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(if (kurz) 10.dp else 20.dp))
// ── Seek bar ─────────────────────────────────────────────── // ── Seek bar ───────────────────────────────────────────────
Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) { Column(Modifier.width(spalte).padding(horizontal = 24.dp)) {
val progress = if (state.durationMs > 0) val progress = if (state.durationMs > 0)
(state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f (state.positionMs.toFloat() / state.durationMs).coerceIn(0f, 1f) else 0f
@@ -408,13 +467,13 @@ fun NowPlayingScreen(
} }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(if (kurz) 4.dp else 12.dp))
// ── Controls ─────────────────────────────────────────────── // ── Controls ───────────────────────────────────────────────
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), modifier = Modifier.width(spalte).padding(horizontal = 16.dp),
) { ) {
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) { NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp), Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
@@ -428,7 +487,7 @@ fun NowPlayingScreen(
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier
.size(68.dp) .size(if (kurz) 56.dp else 68.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.White) .background(Color.White)
.clickable { controller.togglePlay() }, .clickable { controller.togglePlay() },
@@ -452,7 +511,8 @@ fun NowPlayingScreen(
Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint) Icon(icon, null, modifier = Modifier.size(22.dp), tint = tint)
} }
} }
} // end Column (gedeckelte Player-Spalte)
} // end Box (Rollbereich, falls die Seite nicht reicht)
} }
// ── LYRICS TAB ───────────────────────────────────────────────── // ── LYRICS TAB ─────────────────────────────────────────────────
@@ -501,7 +561,8 @@ fun NowPlayingScreen(
if (lyricsState.lines.isNotEmpty()) { if (lyricsState.lines.isNotEmpty()) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), modifier = Modifier.widthIn(max = NP_LISTE_MAX)
.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
) { ) {
Row( Row(
@@ -558,6 +619,8 @@ fun NowPlayingScreen(
state = lyricsListState, state = lyricsListState,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
// Gedeckelt: eine Textzeile ueber 800 dp liest sich nicht mehr.
.widthIn(max = NP_LISTE_MAX)
.fillMaxWidth() .fillMaxWidth()
.onSizeChanged { lyricsViewH = it.height }, .onSizeChanged { lyricsViewH = it.height },
// Asymmetric padding so the first/last lines can still reach the anchor point. // Asymmetric padding so the first/last lines can still reach the anchor point.
@@ -578,7 +641,8 @@ fun NowPlayingScreen(
} }
lyricsState.plainText.isNotBlank() -> LazyColumn( lyricsState.plainText.isNotBlank() -> LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f)
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 24.dp), contentPadding = PaddingValues(vertical = 24.dp),
) { ) {
item { item {
@@ -601,6 +665,7 @@ fun NowPlayingScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.widthIn(max = NP_LISTE_MAX)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp) .padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@@ -660,7 +725,8 @@ fun NowPlayingScreen(
var ziehOffset by remember { mutableFloatStateOf(0f) } var ziehOffset by remember { mutableFloatStateOf(0f) }
val queueLaenge = rememberUpdatedState(state.queue.size) val queueLaenge = rememberUpdatedState(state.queue.size)
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f)
.widthIn(max = NP_LISTE_MAX).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp), contentPadding = PaddingValues(vertical = 4.dp),
) { ) {
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong -> itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
@@ -764,6 +830,7 @@ fun NowPlayingScreen(
} // end inner Column } // end inner Column
} // end HorizontalPager } // end HorizontalPager
} }
} // end BoxWithConstraints (schmal/kurz)
} }
} }