From c62a7e29212fb08631208afed7417ced286f6308 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 31 Aug 2026 07:30:05 +0200 Subject: [PATCH 1/2] feat(lecteur): rendre la file d'attente visible et manipulable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On ne pouvait pas voir ce qui allait être joué. La file existait dans Media3, l'application n'en montrait que le morceau courant. Elle s'ouvre depuis le lecteur plein écran, à la place de la pochette : ce qui joue et de quoi l'arrêter restent sous les yeux pendant qu'on remanie la suite. On peut y sauter à un morceau, le déplacer, le retirer. L'ordre affiché est celui de la file telle qu'elle a été posée, jamais celui du parcours aléatoire. Ce dernier change la façon de traverser la liste, 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. Le déplacement se fait par flèches et non 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. La file n'est relue qu'au changement de timeline. Passer d'un morceau au suivant la relirait sinon en entier, à chaque piste, pour rendre exactement la même liste — le rang, lui, se lit en temps constant. Les gardes sur les rangs sont documentées pour ce qu'elles sont : défensives et non éprouvées. Le `MediaController` d'aujourd'hui borne déjà ce qu'on lui passe, si bien que les retirer ne fait tomber aucun test — le premier que j'avais écrit là-dessus était creux et a été supprimé. C'est l'interface `Player` qu'elles regardent, et celle-ci ne promet rien. Claude-Session: https://claude.ai/code/session_01NaDC2u4EF6J3JMNxnzpdwg --- .../main/java/app/waveflow/MainActivity.kt | 3 + .../playback/Media3PlaybackController.kt | 57 +++++- .../waveflow/playback/PlaybackController.kt | 27 +++ .../waveflow/ui/player/NowPlayingScreen.kt | 81 +++++++-- .../app/waveflow/ui/player/PlayerUiState.kt | 6 + .../app/waveflow/ui/player/PlayerViewModel.kt | 8 + .../app/waveflow/ui/player/QueueScreen.kt | 165 ++++++++++++++++++ .../playback/Media3PlaybackControllerTest.kt | 66 +++++++ .../test/java/app/waveflow/testing/Fakes.kt | 21 +++ 9 files changed, 417 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/app/waveflow/ui/player/QueueScreen.kt 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..7ebcb7d --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt @@ -0,0 +1,165 @@ +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.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), + ) + + 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 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) From 6894bf6062cce4c28f91a87841d420c0b19a6371 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 31 Aug 2026 11:28:15 +0200 Subject: [PATCH 2/2] fix(lecteur): annoncer le morceau en cours dans la file, pas seulement le teinter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La couleur seule désignait la ligne qui joue. Elle ne dit rien à qui écoute l'écran plutôt qu'il ne le regarde, ni à qui distingue mal un accent émeraude d'un texte ordinaire : sur cinq lignes, un lecteur d'écran lisait cinq fois les mêmes mots. La ligne courante porte désormais un `stateDescription`, et elle seule — sans cette réserve, toutes se diraient en lecture et l'annonce ne distinguerait plus rien. Les deux moitiés sont testées. Claude-Session: https://claude.ai/code/session_01NaDC2u4EF6J3JMNxnzpdwg --- .../app/waveflow/ui/player/QueueScreen.kt | 11 ++- .../app/waveflow/ui/player/QueueScreenTest.kt | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/app/waveflow/ui/player/QueueScreenTest.kt diff --git a/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt b/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt index 7ebcb7d..b4ef6ae 100644 --- a/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/QueueScreen.kt @@ -22,6 +22,8 @@ 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 @@ -112,7 +114,12 @@ private fun QueueRow( } else { MaterialTheme.colorScheme.onSurface }, - modifier = Modifier.weight(1f), + 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( @@ -163,3 +170,5 @@ private fun QueueAction( } private val ACTION = 40.dp + +private const val EN_LECTURE = "En cours de lecture" 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)) + } +}