diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 7b9d446..c194e84 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -647,6 +647,9 @@ private fun WaveFlowRoot( onSeek = playerViewModel::seekTo, onToggleShuffle = playerViewModel::toggleShuffle, onCycleRepeat = playerViewModel::cycleRepeatMode, + onPlayQueueItem = playerViewModel::playQueueItem, + onMoveQueueItem = playerViewModel::moveQueueItem, + onRemoveQueueItem = playerViewModel::removeQueueItem, ) } } diff --git a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt index 43e9ae9..c46b001 100644 --- a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt @@ -52,7 +52,13 @@ class Media3PlaybackController( private val playerListener = object : Player.Listener { // Un seul point de synchronisation plutôt qu'un callback par champ : // on relit l'état complet du lecteur à chaque salve d'événements. - override fun onEvents(player: Player, events: Player.Events) = syncFrom(player) + override fun onEvents(player: Player, events: Player.Events) = syncFrom( + player = player, + // La file ne bouge qu'avec la timeline. Passer d'un morceau au + // suivant la relirait sinon en entier, à chaque piste, pour rendre + // exactement la même liste. + refreshQueue = events.contains(Player.EVENT_TIMELINE_CHANGED), + ) } override fun connect() { @@ -149,6 +155,34 @@ class Media3PlaybackController( ctrl.shuffleModeEnabled = !ctrl.shuffleModeEnabled } + override fun playQueueItem(index: Int) { + val ctrl = controller ?: return + if (index !in 0 until ctrl.mediaItemCount) return + ctrl.seekToDefaultPosition(index) + ctrl.play() + } + + /** + * Les rangs viennent d'une liste que l'écran affichait : entre son rendu et + * le geste, la file a pu se vider ou raccourcir. + * + * Ces gardes sont défensives et **non éprouvées** : le `MediaController` + * d'aujourd'hui borne déjà les rangs qu'on lui passe, si bien que les + * retirer ne fait tomber aucun test. C'est l'interface `Player` qu'elles + * regardent, et celle-ci ne promet rien de tel. + */ + override fun moveQueueItem(from: Int, to: Int) { + val ctrl = controller ?: return + if (from !in 0 until ctrl.mediaItemCount || to !in 0 until ctrl.mediaItemCount) return + ctrl.moveMediaItem(from, to) + } + + override fun removeQueueItem(index: Int) { + val ctrl = controller ?: return + if (index !in 0 until ctrl.mediaItemCount) return + ctrl.removeMediaItem(index) + } + override fun cycleRepeatMode() { val ctrl = controller ?: return ctrl.repeatMode = when (ctrl.repeatMode) { @@ -167,7 +201,14 @@ class Media3PlaybackController( _state.value = PlaybackState() } - private fun syncFrom(player: Player) { + /** + * @param refreshQueue relire la file entière, ce qui coûte un parcours de + * la timeline. Inutile quand seul le morceau courant a changé — le rang + * suffit alors, et il se lit en temps constant. + */ + private fun syncFrom(player: Player, refreshQueue: Boolean = true) { + val queue = if (refreshQueue) player.readQueue() else _state.value.queue + _state.value = PlaybackState( isConnected = true, current = player.currentMediaItem?.toPlayingTrack(), @@ -182,6 +223,8 @@ class Media3PlaybackController( else -> RepeatMode.Off }, failure = player.playerError?.toPlaybackFailure(), + queue = queue, + queueIndex = player.currentMediaItemIndex.takeIf { queue.isNotEmpty() } ?: -1, ) if (player.isPlaying) startPositionUpdates() else stopPositionUpdates() @@ -205,6 +248,16 @@ class Media3PlaybackController( positionJob = null } + /** + * La file dans l'ordre où elle a été posée. + * + * L'ordre de la timeline, et non celui du parcours aléatoire : c'est la + * liste que l'utilisateur a constituée, et celle qu'il retrouve en coupant + * la lecture aléatoire. + */ + private fun Player.readQueue(): List = + List(mediaItemCount) { getMediaItemAt(it).toPlayingTrack() } + private companion object { const val POSITION_POLL_MS = 500L } diff --git a/app/src/main/java/app/waveflow/playback/PlaybackController.kt b/app/src/main/java/app/waveflow/playback/PlaybackController.kt index a1bbd7f..f9dd518 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackController.kt @@ -49,6 +49,11 @@ enum class PlaybackFailure { * @property failure panne en cours, `null` tant que le lecteur va bien. Elle * s'efface d'elle-même à la reprise : Media3 oublie son erreur dès qu'on le * prépare à nouveau. + * @property queue la file telle qu'elle a été posée, dans son ordre d'origine. + * La lecture aléatoire ne la réordonne pas : elle change l'ordre de parcours, + * pas la liste — c'est pourquoi la désactiver rend la suite intacte. + * @property queueIndex rang du morceau courant dans [queue], `-1` si la file + * est vide. */ data class PlaybackState( val isConnected: Boolean = false, @@ -60,6 +65,8 @@ data class PlaybackState( val shuffleEnabled: Boolean = false, val repeatMode: RepeatMode = RepeatMode.Off, val failure: PlaybackFailure? = null, + val queue: List = emptyList(), + val queueIndex: Int = -1, ) /** @@ -110,6 +117,26 @@ interface PlaybackController { /** Fait tourner le mode de répétition : Off -> All -> One -> Off. */ fun cycleRepeatMode() + /** Saute au rang [index] de la file et joue. */ + fun playQueueItem(index: Int) + + /** + * Déplace un morceau dans la file. + * + * Déplacer celui qui joue ne l'interrompt pas : Media3 suit la piste + * courante à travers le remaniement, et c'est ce qu'on attend en tirant une + * ligne pendant qu'elle sonne. + */ + fun moveQueueItem(from: Int, to: Int) + + /** + * Retire un morceau de la file. + * + * Retirer celui qui joue enchaîne sur le suivant plutôt que d'arrêter tout : + * l'utilisateur a écarté un morceau, pas demandé le silence. + */ + fun removeQueueItem(index: Int) + /** Libère le contrôleur ; le service, lui, continue de jouer. */ fun release() } diff --git a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt index 8485075..701b533 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -39,6 +40,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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 @@ -74,8 +76,15 @@ fun NowPlayingScreen( onSeek: (Long) -> Unit, onToggleShuffle: () -> Unit, onCycleRepeat: () -> Unit, + onPlayQueueItem: (Int) -> Unit, + onMoveQueueItem: (from: Int, to: Int) -> Unit, + onRemoveQueueItem: (Int) -> Unit, modifier: Modifier = Modifier, ) { + // Local et non remonté : voir la file est une façon de regarder le lecteur, + // pas un état de l'application. Refermer le lecteur la referme. + var queueShown by rememberSaveable { mutableStateOf(false) } + // La file peut se vider pendant l'animation de fermeture : on continue // d'afficher le dernier morceau connu le temps que l'écran redescende, // plutôt que de le faire disparaître d'un coup. @@ -101,20 +110,39 @@ fun NowPlayingScreen( .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 24.dp), ) { - PlayerHeader(track = track, onCollapse = onCollapse) + PlayerHeader( + track = track, + onCollapse = onCollapse, + queueShown = queueShown, + upNextCount = state.upNextCount, + onToggleQueue = { queueShown = !queueShown }, + ) - Spacer(Modifier.weight(1f)) + if (queueShown) { + // La file prend la place de la pochette et non celle de tout + // l'écran : on garde sous les yeux ce qui joue et de quoi + // l'arrêter pendant qu'on remanie la suite. + QueueScreen( + state = state, + onPlayAt = onPlayQueueItem, + onMove = onMoveQueueItem, + onRemove = onRemoveQueueItem, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) - Artwork( - artworkUri = track.artworkUri, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp)), - ) + Artwork( + artworkUri = track.artworkUri, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp)), + ) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.weight(1f)) + } TrackTitle(track = track) @@ -147,7 +175,13 @@ fun NowPlayingScreen( } @Composable -private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) { +private fun PlayerHeader( + track: PlayingTrack, + onCollapse: () -> Unit, + queueShown: Boolean, + upNextCount: Int, + onToggleQueue: () -> Unit, +) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -164,7 +198,7 @@ private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "EN LECTURE", + text = if (queueShown) "FILE D'ATTENTE" else "EN LECTURE", style = MaterialTheme.typography.labelSmall, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -178,8 +212,25 @@ private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) { ) } - // Contrepoids du bouton de gauche pour garder le titre centré. - Spacer(Modifier.size(48.dp)) + // Le contrepoids du bouton de gauche devient utile : il ouvre la file. + IconButton(onClick = onToggleQueue) { + Icon( + imageVector = Icons.AutoMirrored.Filled.QueueMusic, + contentDescription = if (queueShown) { + "Revenir à la pochette" + } else { + // Le nombre est dans la description plutôt qu'affiché : au + // volant comme au lecteur d'écran, « trois morceaux + // ensuite » vaut mieux qu'une pastille. + "Voir la file — $upNextCount ${if (upNextCount > 1) "morceaux" else "morceau"} ensuite" + }, + tint = if (queueShown) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } } diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt index 22d1380..48efa23 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -18,7 +18,13 @@ data class PlayerUiState( val durationMs: Long = 0L, val shuffleEnabled: Boolean = false, val repeatMode: RepeatMode = RepeatMode.Off, + val queue: List = emptyList(), + val queueIndex: Int = -1, ) { + /** Ce qui reste à jouer après le morceau courant. */ + val upNextCount: Int + get() = (queue.size - queueIndex - 1).coerceAtLeast(0) + /** Avancement dans le morceau, entre 0 et 1 (0 si la durée est inconnue). */ val progress: Float get() = if (durationMs > 0L) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt index dc381ec..c090b88 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -43,6 +43,8 @@ class PlayerViewModel( durationMs = playback.durationMs, shuffleEnabled = playback.shuffleEnabled, repeatMode = playback.repeatMode, + queue = playback.queue, + queueIndex = playback.queueIndex, ) }.stateIn( scope = viewModelScope, @@ -129,6 +131,12 @@ class PlayerViewModel( fun cycleRepeatMode() = playbackController.cycleRepeatMode() + fun playQueueItem(index: Int) = playbackController.playQueueItem(index) + + fun moveQueueItem(from: Int, to: Int) = playbackController.moveQueueItem(from, to) + + fun removeQueueItem(index: Int) = playbackController.removeQueueItem(index) + override fun onCleared() { // Le service, lui, survit et continue la lecture en arrière-plan. playbackController.release() diff --git a/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt b/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt new file mode 100644 index 0000000..b4ef6ae --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt @@ -0,0 +1,174 @@ +package app.waveflow.ui.player + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.waveflow.playback.PlayingTrack +import app.waveflow.ui.components.MediaRow + +/** + * La file d'attente : ce qui a été joué, ce qui joue, ce qui suit. + * + * L'ordre affiché est celui de la file telle qu'elle a été posée, et non celui + * du parcours aléatoire. C'est la liste que l'utilisateur a constituée, et + * celle qu'il retrouve en coupant la lecture aléatoire ; montrer un ordre tiré + * au sort lui ferait croire que sa file a été remaniée. + * + * Le déplacement se fait par flèches plutôt qu'au glisser-déposer : celui-ci + * demande une liste qui sache porter le geste, et la file se manipule souvent + * d'une main, en marchant. Deux boutons touchent mieux qu'une poignée. + */ +@Composable +fun QueueScreen( + state: PlayerUiState, + onPlayAt: (Int) -> Unit, + onMove: (from: Int, to: Int) -> Unit, + onRemove: (Int) -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = 0.dp, +) { + if (state.queue.isEmpty()) { + Box( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "La file est vide", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + LazyColumn(modifier = modifier.fillMaxSize()) { + itemsIndexed( + items = state.queue, + // Le rang fait partie de la clé : un même morceau peut figurer + // deux fois dans une file, et deux lignes ne peuvent pas partager + // une identité sans que la liste s'y perde au réordonnancement. + key = { rang, piste -> "$rang:${piste.mediaId}" }, + ) { rang, piste -> + QueueRow( + track = piste, + isCurrent = rang == state.queueIndex, + canMoveUp = rang > 0, + canMoveDown = rang < state.queue.lastIndex, + onClick = { onPlayAt(rang) }, + onUp = { onMove(rang, rang - 1) }, + onDown = { onMove(rang, rang + 1) }, + onRemove = { onRemove(rang) }, + ) + } + item { Spacer(Modifier.height(bottomPadding)) } + } +} + +@Composable +private fun QueueRow( + track: PlayingTrack, + isCurrent: Boolean, + canMoveUp: Boolean, + canMoveDown: Boolean, + onClick: () -> Unit, + onUp: () -> Unit, + onDown: () -> Unit, + onRemove: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + MediaRow( + artworkUri = track.artworkUri, + title = track.title, + subtitle = track.artist ?: "Artiste inconnu", + onClick = onClick, + titleColor = if (isCurrent) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier + .weight(1f) + // La couleur seule désignait le morceau en cours : elle ne dit + // rien à qui écoute l'écran plutôt qu'il ne le regarde, ni à qui + // distingue mal l'accent du texte ordinaire. + .semantics { if (isCurrent) stateDescription = EN_LECTURE }, + ) + + QueueAction( + icon = Icons.Filled.KeyboardArrowUp, + description = "Monter ${track.title}", + enabled = canMoveUp, + onClick = onUp, + ) + QueueAction( + icon = Icons.Filled.KeyboardArrowDown, + description = "Descendre ${track.title}", + enabled = canMoveDown, + onClick = onDown, + ) + QueueAction( + icon = Icons.Filled.Close, + description = "Retirer ${track.title} de la file", + enabled = true, + onClick = onRemove, + ) + } +} + +/** + * Une action de ligne. + * + * La description nomme le morceau : trois fois la même étiquette sur une liste + * ne dirait rien à qui l'écoute plutôt qu'il ne la voit. + */ +@Composable +private fun QueueAction( + icon: ImageVector, + description: String, + enabled: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(ACTION), + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private val ACTION = 40.dp + +private const val EN_LECTURE = "En cours de lecture" diff --git a/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt b/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt index 3292c9d..d858642 100644 --- a/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt +++ b/app/src/test/java/app/waveflow/playback/Media3PlaybackControllerTest.kt @@ -252,6 +252,72 @@ class Media3PlaybackControllerTest { // ------------------------------------------------------------------ /** Le service tel qu'Android le crée, et un contrôleur qui s'y est lié. */ + // ------------------------------------------------------------------ + // La file + // ------------------------------------------------------------------ + + @Test + fun `la file expose ce qui a ete pose, dans l'ordre`() { + val controleur = controleurConnecte() + + controleur.play(listOf(song(1), song(2), song(3)), startIndex = 1) + attendre("la file posée") { controleur.state.value.queue.size == 3 } + + val etat = controleur.state.value + assertEquals(listOf("local:1", "local:2", "local:3"), etat.queue.map { it.mediaId }) + assertEquals(1, etat.queueIndex) + } + + @Test + fun `la lecture aleatoire ne remanie pas la file affichee`() { + // Elle change l'ordre de parcours, pas la liste. L'afficher mélangée + // ferait croire que la file a été remaniée, alors que couper le hasard + // la rendrait intacte. + val controleur = controleurConnecte() + + controleur.play(listOf(song(1), song(2), song(3)), startIndex = 0) + attendre("la file posée") { controleur.state.value.queue.size == 3 } + controleur.toggleShuffle() + attendre("le hasard actif") { controleur.state.value.shuffleEnabled } + + assertEquals( + listOf("local:1", "local:2", "local:3"), + controleur.state.value.queue.map { it.mediaId }, + ) + } + + @Test + fun `retirer un morceau le fait sortir de la file`() { + val controleur = controleurConnecte() + + controleur.play(listOf(song(1), song(2), song(3)), startIndex = 0) + attendre("la file posée") { controleur.state.value.queue.size == 3 } + controleur.removeQueueItem(1) + + attendre("la file raccourcie") { controleur.state.value.queue.size == 2 } + assertEquals( + listOf("local:1", "local:3"), + controleur.state.value.queue.map { it.mediaId }, + ) + } + + @Test + fun `deplacer un morceau change son rang sans toucher aux autres`() { + val controleur = controleurConnecte() + + controleur.play(listOf(song(1), song(2), song(3)), startIndex = 0) + attendre("la file posée") { controleur.state.value.queue.size == 3 } + controleur.moveQueueItem(from = 2, to = 0) + + attendre("le déplacement pris") { + controleur.state.value.queue.firstOrNull()?.mediaId == "local:3" + } + assertEquals( + listOf("local:3", "local:1", "local:2"), + controleur.state.value.queue.map { it.mediaId }, + ) + } + private fun controleurConnecte(): Media3PlaybackController { val demarre = Robolectric.buildService(PlaybackService::class.java).create() service = demarre diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index 2a57b1d..00e2bae 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -9,6 +9,7 @@ import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackState +import app.waveflow.playback.PlayingTrack import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -148,6 +149,26 @@ class FakePlaybackController : PlaybackController { val playShuffledCalls = mutableListOf>() val playRemoteShuffledCalls = mutableListOf>() + /** Les gestes sur la file, dans l'ordre où l'écran les a demandés. */ + val queueCalls = mutableListOf() + + override fun playQueueItem(index: Int) { + queueCalls += "play:$index" + } + + override fun moveQueueItem(from: Int, to: Int) { + queueCalls += "move:$from->$to" + } + + override fun removeQueueItem(index: Int) { + queueCalls += "remove:$index" + } + + /** Pose une file, comme si le lecteur l'avait acceptée. */ + fun setQueue(queue: List, index: Int) { + _state.value = _state.value.copy(queue = queue, queueIndex = index) + } + override fun connect() { connectCount++ _state.value = _state.value.copy(isConnected = true) diff --git a/app/src/test/java/app/waveflow/ui/player/QueueScreenTest.kt b/app/src/test/java/app/waveflow/ui/player/QueueScreenTest.kt new file mode 100644 index 0000000..b8f3b45 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/player/QueueScreenTest.kt @@ -0,0 +1,78 @@ +package app.waveflow.ui.player + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import app.waveflow.playback.PlayingTrack +import app.waveflow.playback.TrackSource +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Ce que la file dit du morceau en cours. + * + * Elle le distinguait par la couleur seule. Un lecteur d'écran lit les mêmes + * mots sur les cinq lignes, et rien n'y désigne celle qui sonne. + */ +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w411dp-h891dp-xhdpi") +class QueueScreenTest { + + @get:Rule + val compose = createComposeRule() + + private fun piste(id: String, titre: String) = PlayingTrack( + mediaId = id, + title = titre, + artist = "Bruit de Fond", + album = null, + artworkUri = null, + localSongId = null, + source = TrackSource.Local, + ) + + private fun afficher(index: Int) { + compose.setContent { + QueueScreen( + state = PlayerUiState( + queue = listOf( + piste("local:1", "Première"), + piste("local:2", "Deuxième"), + piste("local:3", "Troisième"), + ), + queueIndex = index, + ), + onPlayAt = {}, + onMove = { _, _ -> }, + onRemove = {}, + ) + } + } + + private fun etatAnnonce(valeur: String) = + SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, valeur) + + @Test + fun `le morceau en cours s'annonce comme tel`() { + afficher(index = 1) + + compose.onNodeWithText("Deuxième").assert(etatAnnonce("En cours de lecture")) + } + + @Test + fun `les autres lignes n'annoncent aucun etat`() { + // Sans cette réserve, toutes les lignes se diraient en lecture et + // l'annonce ne distinguerait plus rien. + afficher(index = 1) + + compose.onNodeWithText("Première") + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.StateDescription)) + compose.onNodeWithText("Troisième") + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.StateDescription)) + } +}