From 624832959d585dd23b6016bb4e7cbcbeb17a8b07 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 13 Oct 2026 18:47:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(android):=20Now-Playing=20an=20Fold=20und?= =?UTF-8?q?=20Flip=20anpassen=20=E2=80=94=20Deckel,=20Hoehenmass,=20Rollbe?= =?UTF-8?q?reich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 30 +++++ android/app/build.gradle.kts | 4 +- .../app/ui/screen/NowPlayingScreen.kt | 115 ++++++++++++++---- 3 files changed, 123 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f96332..5246691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 **Android:** 1.25 (versionCode 28) → 1.26 (versionCode 29). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1ae14ee..8bee9f0 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 = 29 - versionName = "1.26" + versionCode = 30 + versionName = "1.27" // 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/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index 0a4b140..cc5db41 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -68,6 +68,27 @@ import androidx.compose.ui.input.pointer.positionChange 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 fun NowPlayingScreen( state: PlayerState, @@ -167,11 +188,18 @@ fun NowPlayingScreen( // Light scrim for readability 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( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() - .padding(top = topPad, bottom = maxOf(navBarBottom, 20.dp)) + .padding(top = obenPad, bottom = maxOf(navBarBottom, if (kurz) 8.dp else 20.dp)) .pointerInput(Unit) { var dragY = 0f detectVerticalDragGestures( @@ -185,9 +213,13 @@ fun NowPlayingScreen( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 28.dp), + modifier = Modifier + // Ohne Grenze stehen Schliessen-Knopf und Menue auf einem breiten Display + // 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) { Icon(Icons.Default.KeyboardArrowDown, "Schließen", @@ -209,7 +241,10 @@ fun NowPlayingScreen( .clip(RoundedCornerShape(16.dp)) .background(if (sel) Color.White.copy(0.18f) else Color.Transparent) .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( when (t) { @@ -217,7 +252,8 @@ fun NowPlayingScreen( NpTab.LYRICS -> "Lyrics" 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), ) } @@ -265,20 +301,43 @@ fun NowPlayingScreen( ) { when (NpTab.entries[page]) { // ── PLAYER TAB ───────────────────────────────────────────────── - NpTab.PLAYER -> { + NpTab.PLAYER -> BoxWithConstraints(Modifier.fillMaxSize()) { 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)) - - // Cover art — full content width, scale-animates like Apple Music + // Passt es, wird zentriert; passt es nicht, wird gescrollt. Beides mit einem + // Aufbau: der Inhalt ist mindestens so hoch wie die Seite (dann greift + // 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( model = coverUrl, contentDescription = song?.title, contentScale = ContentScale.Crop, modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth() - .aspectRatio(1f) + .size(coverKante) .graphicsLayer { scaleX = coverScale; scaleY = coverScale } .shadow(40.dp, 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 ────────────────────────────────────────────── - Column(Modifier.fillMaxWidth().padding(horizontal = 28.dp)) { + Column(Modifier.width(spalte).padding(horizontal = 28.dp)) { Row( verticalAlignment = Alignment.CenterVertically, 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 ─────────────────────────────────────────────── - Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp)) { + Column(Modifier.width(spalte).padding(horizontal = 24.dp)) { val progress = if (state.durationMs > 0) (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 ─────────────────────────────────────────────── Row( verticalAlignment = Alignment.CenterVertically, 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) { Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp), @@ -428,7 +487,7 @@ fun NowPlayingScreen( Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(68.dp) + .size(if (kurz) 56.dp else 68.dp) .clip(CircleShape) .background(Color.White) .clickable { controller.togglePlay() }, @@ -452,7 +511,8 @@ fun NowPlayingScreen( 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 ───────────────────────────────────────────────── @@ -501,7 +561,8 @@ fun NowPlayingScreen( if (lyricsState.lines.isNotEmpty()) { Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + modifier = Modifier.widthIn(max = NP_LISTE_MAX) + .fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.End, ) { Row( @@ -558,6 +619,8 @@ fun NowPlayingScreen( state = lyricsListState, modifier = Modifier .weight(1f) + // Gedeckelt: eine Textzeile ueber 800 dp liest sich nicht mehr. + .widthIn(max = NP_LISTE_MAX) .fillMaxWidth() .onSizeChanged { lyricsViewH = it.height }, // Asymmetric padding so the first/last lines can still reach the anchor point. @@ -578,7 +641,8 @@ fun NowPlayingScreen( } lyricsState.plainText.isNotBlank() -> LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth(), + modifier = Modifier.weight(1f) + .widthIn(max = NP_LISTE_MAX).fillMaxWidth(), contentPadding = PaddingValues(vertical = 24.dp), ) { item { @@ -601,6 +665,7 @@ fun NowPlayingScreen( Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .widthIn(max = NP_LISTE_MAX) .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 10.dp) .clip(RoundedCornerShape(12.dp)) @@ -660,7 +725,8 @@ fun NowPlayingScreen( var ziehOffset by remember { mutableFloatStateOf(0f) } val queueLaenge = rememberUpdatedState(state.queue.size) LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth(), + modifier = Modifier.weight(1f) + .widthIn(max = NP_LISTE_MAX).fillMaxWidth(), contentPadding = PaddingValues(vertical = 4.dp), ) { itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong -> @@ -764,6 +830,7 @@ fun NowPlayingScreen( } // end inner Column } // end HorizontalPager } + } // end BoxWithConstraints (schmal/kurz) } }