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) } }