From 2641a6df44289dabb8614ed159fff9d26432a4dc Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 22 Sep 2026 20:45:20 +0200 Subject: [PATCH] fix(android): Rollbalken verschluckte das Kontextmenue der Songzeilen Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +- android/app/build.gradle.kts | 4 +- .../novasonic/app/ui/component/Rollbalken.kt | 92 +++++++++++-------- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c82cc04..30dc83d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,7 +80,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d ## [3.1] — 2026-09-22 -**Android:** 1.15 (versionCode 18) → 1.19 (versionCode 22). 1.16 brachte den Rollbalken, 1.17 und 1.18 machten ihn bedienbar, 1.19 bringt das Bearbeiten der Warteschlange. +**Android:** 1.15 (versionCode 18) → 1.20 (versionCode 23). 1.16 brachte den Rollbalken, 1.17 bis 1.20 machten ihn bedienbar, 1.19 bringt das Bearbeiten der Warteschlange. ### Added - **Die Warteschlange lässt sich bearbeiten (Web + Android)** — bisher war sie eine reine Anzeige: @@ -150,6 +150,12 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d - Gescrollt wird außerdem nicht mehr im Zeigerereignis, sondern in **einem** Effekt, der die Zielposition verfolgt. Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe, die sich gegenseitig überholten. + - **Nachtrag 1.20: der Balken verschluckte das ⋮ der Songzeilen.** Compose reicht eine Berührung + nur an das **oberste** getroffene Element weiter — der Greifstreifen lag über die volle Höhe + am rechten Rand und nahm damit jeden Tipper auf das Kontextmenü entgegen, obwohl er ihn gar + nicht wollte. Jetzt hört nur noch der **Daumen** zu (mit 10 dp Luft darüber und darunter); der + Rahmen darum hat kein `pointerInput` und ist dadurch durchlässig. Der Streifen ist außerdem von + 28 auf 24 dp geschrumpft. - **„Neu von deinen Künstlern" sitzt jetzt in den Favoriten (Web)** — es stand links unter *Entdecken*, in Android dagegen als Knopf „✨ Neu" in den Favoriten. Zwei Plattformen, zwei Orte für dieselbe Sache. Das Web folgt jetzt Android: der Eintrag in der Seitenleiste ist weg, in der diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 97249ba..faee461 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 = 22 - versionName = "1.19" + versionCode = 23 + versionName = "1.20" // 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/component/Rollbalken.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt index 61be3f3..e4c8014 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt @@ -53,13 +53,19 @@ import kotlin.math.roundToInt * * Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt. * Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe. + * + * Und eine vierte, die erst nach dem Ausliefern auffiel: **nur der Daumen nimmt Beruehrungen an, + * nicht die ganze Spur.** Compose reicht eine Beruehrung nur an das oberste getroffene Element + * weiter — ein Streifen ueber die volle Hoehe verschluckte damit das ⋮ jeder Songzeile, obwohl er + * die Beruehrung gar nicht wollte. Der aeussere Rahmen hat deshalb KEIN `pointerInput` und ist + * dadurch durchlaessig; nur der Daumen selbst hoert zu, mit etwas Luft nach oben und unten. */ @Composable fun Rollbalken( state: LazyListState, modifier: Modifier = Modifier, breite: Dp = 6.dp, - griffBreite: Dp = 28.dp, + griffBreite: Dp = 24.dp, minHoehe: Dp = 44.dp, farbe: Color = Color.White, ) { @@ -90,7 +96,7 @@ fun Rollbalken( val anteil = if (ziehen) zielPos / maxIndex else state.firstVisibleItemIndex.toFloat() / maxIndex val daumenY = frei * anteil.coerceIn(0f, 1f) - val spielPx = with(dichte) { 24.dp.toPx() } + val spielPx = with(dichte) { 10.dp.toPx() } // Luft ober- und unterhalb des Daumens // Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben. val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx)) @@ -106,47 +112,55 @@ fun Rollbalken( .width(griffBreite) .fillMaxHeight() .onSizeChanged { spurPx = it.height.toFloat() } - .pointerInput(Unit) { - awaitEachGesture { - val start = awaitFirstDown(requireUnconsumed = false) - val g = geo.value - val aufDemDaumen = start.position.y >= g.daumenY - g.spiel && - start.position.y <= g.daumenY + g.daumenPx + g.spiel - if (!aufDemDaumen || g.frei <= 0f) return@awaitEachGesture - start.consume() - zielPos = state.firstVisibleItemIndex.toFloat() - ziehen = true - try { - while (true) { - val ereignis = awaitPointerEvent() - val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break - if (!punkt.pressed) break - val delta = punkt.positionChange().y - punkt.consume() - if (delta != 0f) { - val jetzt = geo.value - if (jetzt.frei > 0f) { - zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei) - .coerceIn(0f, jetzt.maxIndex) - } - } - } - } finally { - ziehen = false - } - } - } ) { + val luftDp = with(dichte) { spielPx.toDp() } Box( Modifier .align(Alignment.TopEnd) - .offset { IntOffset(0, daumenY.roundToInt()) } - .padding(end = 2.dp) - .width(breite) - .height(with(dichte) { daumenPx.toDp() }) - .clip(RoundedCornerShape(50)) - .background(farbe.copy(alpha = alpha)) - ) + // Der Greifbereich beginnt eine Spielbreite ueber dem Daumen und ist entsprechend + // hoeher; der sichtbare Balken sitzt durch das Innenmass wieder genau auf daumenY. + .offset { IntOffset(0, (daumenY - spielPx).roundToInt()) } + .width(griffBreite) + .height(with(dichte) { (daumenPx + 2 * spielPx).toDp() }) + .pointerInput(Unit) { + awaitEachGesture { + val start = awaitFirstDown(requireUnconsumed = false) + if (geo.value.frei <= 0f) return@awaitEachGesture + start.consume() + zielPos = state.firstVisibleItemIndex.toFloat() + ziehen = true + try { + while (true) { + val ereignis = awaitPointerEvent() + val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break + if (!punkt.pressed) break + val delta = punkt.positionChange().y + punkt.consume() + if (delta != 0f) { + val jetzt = geo.value + if (jetzt.frei > 0f) { + zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei) + .coerceIn(0f, jetzt.maxIndex) + } + } + } + } finally { + ziehen = false + } + } + } + .padding(vertical = luftDp, horizontal = 0.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Box( + Modifier + .padding(end = 2.dp) + .width(breite) + .fillMaxHeight() + .clip(RoundedCornerShape(50)) + .background(farbe.copy(alpha = alpha)) + ) + } } }