Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 10 additions & 19 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
)
}
}
}

Expand Down
70 changes: 70 additions & 0 deletions app/src/main/java/app/waveflow/ui/player/MiniPlayerHost.kt
Original file line number Diff line number Diff line change
@@ -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
172 changes: 172 additions & 0 deletions app/src/test/java/app/waveflow/ui/player/MiniPlayerHostTest.kt
Original file line number Diff line number Diff line change
@@ -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
}
}