From c8823747ab5cc056bd39c5437e110bf6ccdd5f33 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 19 Aug 2026 00:14:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(lecture):=20r=C3=A9server=20sous=20les=20li?= =?UTF-8?q?stes=20la=20hauteur=20qu'occupe=20vraiment=20le=20mini-lecteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La réserve était un nombre écrit en dur, 76 dp, pour une carte qui en occupe 82 aux réglages par défaut. Le bas de la dernière ligne disparaissait donc sous le mini-lecteur, de 6 dp — et de 27,5 dp pour qui double la taille des caractères, la carte passant alors à 103,5 dp. Les trois chiffres sont mesurés, pas estimés. La passation attribuait le défaut au paysage. Il n'en est rien : la carte fait la même hauteur dans les deux orientations, à toutes les densités, et le manque est le même partout. Ce qui le creuse, c'est la taille de police du système — un réglage d'accessibilité, donc exactement les utilisateurs qu'un texte tronqué gêne le plus. `MiniPlayerHost` mesure la carte et passe le résultat aux listes. Aucune constante ne pouvait être juste, puisque la hauteur dépend de réglages extérieurs à l'application ; une mesure suit aussi la moindre retouche du mini-lecteur, ce qu'un nombre rangé dans un autre fichier ne fait pas. Le `MiniPlayerSpaceEstimate` qui subsiste ne sert qu'à éviter un saut sur la première image, avant que la mesure n'arrive. L'extraction sort du même coup l'agencement de `MainActivity`, où il n'était atteignable par aucun test. Les trois tests comparent la réserve annoncée à la hauteur réellement occupée, dans le même environnement — ils ne fixent aucun nombre. Rétablir la constante de 76 dp les fait tomber tous les trois, et eux seuls sur 258. --- .../main/java/app/waveflow/MainActivity.kt | 29 +-- .../app/waveflow/ui/player/MiniPlayerHost.kt | 70 +++++++ .../waveflow/ui/player/MiniPlayerHostTest.kt | 172 ++++++++++++++++++ 3 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt create mode 100644 app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index c078ebe..d7bc763 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -60,7 +59,7 @@ import app.waveflow.ui.navigation.TopLevelDestination import app.waveflow.ui.navigation.longArgOf import app.waveflow.ui.navigation.WaveFlowBottomBar import app.waveflow.ui.permission.AudioPermissionGate -import app.waveflow.ui.player.MiniPlayer +import app.waveflow.ui.player.MiniPlayerHost import app.waveflow.ui.player.NowPlayingScreen import app.waveflow.ui.player.PlayerViewModel import app.waveflow.ui.playlists.AddToPlaylistSheet @@ -93,9 +92,6 @@ class MainActivity : ComponentActivity() { } } -/** Hauteur réservée sous les listes pour que le mini-player ne masque rien. */ -private val MiniPlayerSpace = 76.dp - private const val PLAYER_TRANSITION_MS = 300 /** Routes affichant une flèche de retour plutôt que le titre de section. */ @@ -170,7 +166,6 @@ private fun WaveFlowRoot() { BackHandler(enabled = playerExpanded) { playerExpanded = false } - val listBottomPadding = if (hasTrack) MiniPlayerSpace else 0.dp val isDetailRoute = currentRoute in DETAIL_ROUTES val openPlaylist = backStackEntry ?.takeIf { currentRoute == Routes.PLAYLIST_DETAIL } @@ -310,11 +305,18 @@ private fun WaveFlowRoot() { } } - Box( + MiniPlayerHost( + state = playerState, + // Inutile de le composer sous le lecteur plein écran, qui le + // recouvre entièrement. + showMiniPlayer = !playerExpanded, + onExpand = { playerExpanded = true }, + onTogglePlayPause = playerViewModel::togglePlayPause, + onSkipNext = playerViewModel::skipNext, modifier = Modifier .fillMaxSize() .padding(innerPadding), - ) { + ) { listBottomPadding -> NavHost( navController = navController, startDestination = Routes.SONGS, @@ -580,17 +582,6 @@ private fun WaveFlowRoot() { } } - // Inutile de le composer sous le lecteur plein écran, qui - // le recouvre entièrement. - if (!playerExpanded) { - MiniPlayer( - state = playerState, - onExpand = { playerExpanded = true }, - onTogglePlayPause = playerViewModel::togglePlayPause, - onSkipNext = playerViewModel::skipNext, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } } } diff --git a/app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt b/app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt new file mode 100644 index 0000000..76b1fab --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt @@ -0,0 +1,70 @@ +package app.waveflow.ui.player + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Le contenu de l'application, surmonté du mini-lecteur flottant. + * + * La réserve laissée sous les listes est **mesurée sur le mini-lecteur**, jamais + * devinée. Une constante ne pouvait pas être juste : la carte occupe 82 dp aux + * réglages par défaut et 103,5 dp à polices doublées, alors que les 76 dp + * réservés jusqu'ici masquaient le bas de la dernière ligne — de 6 dp en temps + * ordinaire, de 27 pour qui agrandit les caractères. Une mesure suit aussi la + * moindre retouche du mini-lecteur, ce qu'un nombre écrit ailleurs ne fait pas. + * + * @param showMiniPlayer `false` sous le lecteur plein écran, qui le recouvre + * entièrement : inutile de le composer, et la liste n'a alors rien à réserver. + * @param content reçoit la réserve à passer en `contentPadding` de ses listes. + */ +@Composable +fun MiniPlayerHost( + state: PlayerUiState, + showMiniPlayer: Boolean, + onExpand: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable (bottomPadding: Dp) -> Unit, +) { + val density = LocalDensity.current + var mesure by remember { mutableStateOf(MiniPlayerSpaceEstimate) } + + // Le mini-lecteur ne s'affiche pas sans morceau : la liste n'a alors rien à + // réserver, et la mesure retenue attendra le prochain. + val visible = showMiniPlayer && state.track != null + + Box(modifier = modifier) { + content(if (visible) mesure else 0.dp) + + if (visible) { + MiniPlayer( + state = state, + onExpand = onExpand, + onTogglePlayPause = onTogglePlayPause, + onSkipNext = onSkipNext, + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { mesure = with(density) { it.height.toDp() } }, + ) + } + } +} + +/** + * Ce que vaut la réserve avant la première mesure. + * + * La carte fait 82 dp aux réglages par défaut : cette valeur n'a qu'à éviter que + * la liste ne saute entre la première image et la suivante. + */ +private val MiniPlayerSpaceEstimate = 82.dp diff --git a/app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt b/app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt new file mode 100644 index 0000000..8fe041e --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt @@ -0,0 +1,172 @@ +package app.waveflow.ui.player + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import app.waveflow.playback.PlayingTrack +import app.waveflow.playback.TrackSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * La place que les listes laissent au mini-lecteur. + * + * Elle était écrite en dur : 76 dp, pour une carte qui en occupe 82 aux + * réglages par défaut et 103,5 à polices doublées. Le bas de la dernière ligne + * disparaissait donc sous la carte — un peu pour tout le monde, beaucoup pour + * qui agrandit les caractères. + * + * Ces tests ne fixent aucun nombre : ils comparent la réserve annoncée à la + * hauteur que la carte occupe vraiment, dans le même environnement. Un nombre + * écrit en dur ne peut satisfaire les deux réglages de police à la fois. + */ +@RunWith(RobolectricTestRunner::class) +class MiniPlayerHostTest { + + @get:Rule + val compose = createComposeRule() + + private val track = PlayingTrack( + mediaId = "remote:a", + title = "Résonance", + artist = "Bruit de Fond", + album = "Écho", + artworkUri = null, + localSongId = null, + source = TrackSource.Remote, + ) + + private val state = PlayerUiState(track = track, isPlaying = true) + + /** Ce que l'hôte annonce aux listes, et ce que la carte occupe réellement. */ + private class Mesures { + var reserve: Dp = Dp.Unspecified + var carte: Dp = Dp.Unspecified + } + + private fun poser(): Mesures { + val mesures = Mesures() + + compose.setContent { + val density = LocalDensity.current + + Box(Modifier.fillMaxSize()) { + MiniPlayerHost( + state = state, + showMiniPlayer = true, + onExpand = {}, + onTogglePlayPause = {}, + onSkipNext = {}, + modifier = Modifier.fillMaxSize(), + ) { reserve -> + mesures.reserve = reserve + ListeTemoin(reserve) + } + + // La même carte, mesurée à part : c'est elle l'étalon, et non un + // nombre que ce test se donnerait à lui-même. Sa hauteur ne + // dépend pas de la largeur, les deux lignes de texte restant + // sur une ligne chacune. + MiniPlayer( + state = state, + onExpand = {}, + onTogglePlayPause = {}, + onSkipNext = {}, + modifier = Modifier + .align(Alignment.TopCenter) + .onSizeChanged { mesures.carte = with(density) { it.height.toDp() } }, + ) + } + } + + compose.waitForIdle() + return mesures + } + + @Composable + private fun ListeTemoin(reserve: Dp) { + LazyColumn( + contentPadding = PaddingValues(bottom = reserve), + modifier = Modifier + .fillMaxSize() + .testTag(LISTE), + ) { + items((0 until LIGNES).toList()) { index -> + Text( + text = "Ligne $index", + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .testTag("ligne-$index"), + ) + } + } + } + + @Test + @Config(qualifiers = "w411dp-h891dp-xhdpi") + fun `la reserve sous les listes vaut la hauteur de la carte`() { + val mesures = poser() + + assertEquals(mesures.carte, mesures.reserve) + } + + @Test + @Config(qualifiers = "w411dp-h891dp-xhdpi", fontScale = 2.0f) + fun `a polices doublees la carte grandit et la reserve avec elle`() { + // Le cas qu'aucune constante ne peut couvrir : la carte grandit avec les + // réglages d'accessibilité du système, la réserve doit suivre. + val mesures = poser() + + assertEquals(mesures.carte, mesures.reserve) + } + + @Test + @Config(qualifiers = "w411dp-h891dp-xhdpi") + fun `la derniere ligne reste entierement visible sous la carte`() { + // La propriété que l'utilisateur constate : arrivé au bas de la liste, + // rien ne se cache derrière le mini-lecteur. + val mesures = poser() + + compose.onNodeWithTag(LISTE).performScrollToIndex(LIGNES - 1) + compose.waitForIdle() + + val racine = compose.onRoot().getUnclippedBoundsInRoot() + val derniere = compose.onNodeWithTag("ligne-${LIGNES - 1}").getUnclippedBoundsInRoot() + val hautDeLaCarte = racine.height - mesures.carte + + assertTrue( + "La dernière ligne finit à ${derniere.bottom}, la carte commence à $hautDeLaCarte", + derniere.bottom <= hautDeLaCarte, + ) + } + + private companion object { + const val LISTE = "liste-temoin" + const val LIGNES = 30 + } +}