From a172540a6db2fc5b56e20f4a021bd2ea09ca511c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 13 Aug 2026 10:54:39 +0200 Subject: [PATCH 1/2] feat(serveur): lancer un album distant en entier ou au hasard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'écran d'un album distant n'avait que ses morceaux cliquables, là où un album local propose Lecture et Aléatoire. L'asymétrie n'avait plus de raison d'être depuis que la lecture distante fonctionne. `DetailHeader` est réutilisé tel quel : il ne connaît que des chaînes, une image et deux rappels. Le motif invoqué pour en maintenir un second — « rien n'est encore lisible depuis le serveur » — est tombé avec la PR précédente. Le détail d'un artiste garde, lui, un en-tête sans commandes : il rend ses albums et non ses pistes, il n'y a pas de file à lancer sans charger chaque album d'abord. `playRemoteShuffled` pose le mode aléatoire avant les items, comme la file locale : Media3 construit son ordre de lecture à leur arrivée. Le test d'écran vérifie que Lecture et Aléatoire restent deux commandes distinctes — le piège déjà rencontré sur les albums locaux, où les deux boutons avaient fini par faire la même chose. Il tombe si l'un est câblé sur l'autre. Validé contre un waveflow-server local : les trois pistes de l'album sont individuellement lisibles, ce qui est la condition d'une file aléatoire. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- README.md | 6 +- .../main/java/app/waveflow/MainActivity.kt | 6 + .../playback/Media3PlaybackController.kt | 12 ++ .../waveflow/playback/PlaybackController.kt | 3 + .../app/waveflow/ui/player/PlayerViewModel.kt | 3 + .../ui/server/catalog/RemoteDetailScreens.kt | 41 ++++--- .../test/java/app/waveflow/testing/Fakes.kt | 5 + .../waveflow/ui/player/PlayerViewModelTest.kt | 10 ++ .../catalog/RemoteAlbumDetailScreenTest.kt | 112 ++++++++++++++++++ 9 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 app/src/test/java/app/waveflow/ui/server/catalog/RemoteAlbumDetailScreenTest.kt diff --git a/README.md b/README.md index 213601e..4508b3a 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ in-memory SQLite for Room, so the DAO is exercised without a device. | `CatalogViewModelTest` | paging, end of list, in-flight guard, clear on sign-out | | `MediaItemMapperTest` | local vs remote track identity, unreachable marker URI | | `RemoteStreamResolverTest` | ticket swap, local passthrough, DataSpec preserved | +| `RemoteAlbumDetailScreenTest` | Play and Shuffle are distinct, empty album, track tap | | `ArtworkUrlsTest` | URL only when a cover exists, proxy prefix, invalid address | | `ServerImageAuthInterceptorTest` | signing scope, third-party host, refresh on 401 | @@ -170,8 +171,9 @@ into `DragState` and tested there instead. The **Server** tab signs in to a [WaveFlow Server](https://github.com/InstaZDLL/waveflow-server), keeps the session alive -and browses its catalogue — albums, artists, and what each contains. Tapping a -remote track plays it. Nothing of the local library is sent anywhere. +and browses its catalogue — albums, artists, and what each contains. A remote +album plays like a local one: tap a track, or use Play / Shuffle. Nothing of the +local library is sent anywhere. The two sources stay separate by design: the tab is its own section rather than a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` / diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 11d795d..e2c6ba1 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -451,6 +451,12 @@ private fun WaveFlowRoot() { playerViewModel.playRemoteFrom(it, song) } }, + onPlay = { + detail.value?.songs?.let(playerViewModel::playRemoteFirst) + }, + onShuffle = { + detail.value?.songs?.let(playerViewModel::playRemoteShuffled) + }, onRetry = { catalogViewModel.openAlbum(albumId) }, bottomPadding = listBottomPadding, ) diff --git a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt index 8d4a3cf..077f6b5 100644 --- a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt @@ -113,6 +113,18 @@ class Media3PlaybackController( ctrl.play() } + override fun playRemoteShuffled(songs: List) { + val ctrl = controller ?: return + if (songs.isEmpty()) return + + // Comme pour la file locale : le mode aléatoire doit être posé avant + // les items, Media3 construisant son ordre de lecture à leur arrivée. + ctrl.shuffleModeEnabled = true + ctrl.setMediaItems(songs.map { it.toMediaItem() }, songs.indices.random(), 0L) + ctrl.prepare() + ctrl.play() + } + override fun playPause() { val ctrl = controller ?: return if (ctrl.isPlaying) ctrl.pause() else ctrl.play() diff --git a/app/src/main/java/app/waveflow/playback/PlaybackController.kt b/app/src/main/java/app/waveflow/playback/PlaybackController.kt index 07ae430..0c6e5f0 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackController.kt @@ -71,6 +71,9 @@ interface PlaybackController { */ fun playShuffled(songs: List) + /** Même chose pour des morceaux du serveur. */ + fun playRemoteShuffled(songs: List) + fun playPause() fun skipNext() 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 d04c944..8b7a63f 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -81,6 +81,9 @@ class PlayerViewModel( queue.firstOrNull()?.let { playRemoteFrom(queue, it) } } + fun playRemoteShuffled(queue: List) = + playbackController.playRemoteShuffled(queue) + fun togglePlayPause() = playbackController.playPause() fun skipNext() = playbackController.skipNext() diff --git a/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt index 769dfb1..82ea10b 100644 --- a/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt +++ b/app/src/main/java/app/waveflow/ui/server/catalog/RemoteDetailScreens.kt @@ -21,6 +21,7 @@ import app.waveflow.model.RemoteSong import app.waveflow.playback.mediaId import app.waveflow.model.orUnknownArtist import app.waveflow.ui.albumCountLabel +import app.waveflow.ui.browse.DetailHeader import app.waveflow.ui.components.CenteredMessage import app.waveflow.ui.components.MediaRow import app.waveflow.ui.formatDuration @@ -32,6 +33,8 @@ fun RemoteAlbumDetailScreen( state: AlbumDetailState, nowPlayingMediaId: String?, onSongClick: (RemoteSong) -> Unit, + onPlay: () -> Unit, + onShuffle: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp, @@ -42,14 +45,18 @@ fun RemoteAlbumDetailScreen( modifier = Modifier.fillMaxSize(), ) { item { - RemoteDetailHeader( + // Le même en-tête que les albums locaux : il ne connaît que des + // chaînes, une image et deux rappels. Rien ne justifiait d'en + // maintenir un second une fois la lecture distante branchée. + DetailHeader( artworkUri = detail.album.artworkUri, title = detail.album.title, subtitle = detail.album.artist.orUnknownArtist(), - summary = listOfNotNull( - trackCountLabel(detail.songs.size), - detail.album.year?.toString(), - ).joinToString(" · "), + trackCount = detail.songs.size, + durationMs = detail.songs.sumOf { it.durationMs }, + onPlay = onPlay, + onShuffle = onShuffle, + playEnabled = detail.songs.isNotEmpty(), ) } @@ -79,10 +86,12 @@ fun RemoteArtistDetailScreen( modifier = Modifier.fillMaxSize(), ) { item { - RemoteDetailHeader( + // Sans boutons, contrairement à l'album : le détail d'un artiste + // rend ses albums, pas ses pistes. Proposer « Lecture » ici + // demanderait de charger chaque album d'abord. + RemoteArtistHeader( artworkUri = detail.artist.artworkUri, - title = detail.artist.name, - subtitle = "Artiste", + name = detail.artist.name, summary = albumCountLabel(detail.artist.albumCount ?: detail.albums.size), ) } @@ -133,23 +142,21 @@ private fun DetailContainer( } /** - * En-tête d'un détail distant. + * En-tête d'un artiste distant. * - * `DetailHeader` de la navigation locale n'est pas réutilisé : il porte les - * boutons Lecture et Aléatoire, or rien n'est encore lisible depuis le serveur. - * Proposer des commandes inertes serait pire que de ne pas les montrer. + * Volontairement dépourvu de commandes : le détail d'un artiste rend ses + * albums et non ses pistes, il n'y a donc pas de file à lancer. */ @Composable -private fun RemoteDetailHeader( +private fun RemoteArtistHeader( artworkUri: android.net.Uri?, - title: String, - subtitle: String, + name: String, summary: String, ) { MediaRow( artworkUri = artworkUri, - title = title, - subtitle = listOf(subtitle, summary).filter { it.isNotBlank() }.joinToString(" · "), + title = name, + subtitle = listOf("Artiste", summary).filter { it.isNotBlank() }.joinToString(" · "), artworkShape = CircleShape, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index d60162d..22d5ffb 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -144,6 +144,7 @@ class FakePlaybackController : PlaybackController { val playCalls = mutableListOf, Int>>() val playRemoteCalls = mutableListOf, Int>>() val playShuffledCalls = mutableListOf>() + val playRemoteShuffledCalls = mutableListOf>() override fun connect() { connectCount++ @@ -162,6 +163,10 @@ class FakePlaybackController : PlaybackController { playShuffledCalls += songs } + override fun playRemoteShuffled(songs: List) { + playRemoteShuffledCalls += songs + } + override fun playPause() = Unit override fun skipNext() = Unit diff --git a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt index dd459b9..10ecdea 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -84,6 +84,16 @@ class PlayerViewModelTest { assertTrue(controller.playRemoteCalls.isEmpty()) } + @Test + fun `l'aleatoire distant passe par la file distante`() = runTest { + val viewModel = PlayerViewModel(controller) + + viewModel.playRemoteShuffled(remoteSongs) + + assertEquals(listOf(remoteSongs), controller.playRemoteShuffledCalls) + assertTrue("la file locale ne doit pas être touchée", controller.playShuffledCalls.isEmpty()) + } + @Test fun `l'etat reprend la piste telle que le lecteur la decrit`() = runTest { // Plus de résolution dans la bibliothèque : une piste du serveur n'y diff --git a/app/src/test/java/app/waveflow/ui/server/catalog/RemoteAlbumDetailScreenTest.kt b/app/src/test/java/app/waveflow/ui/server/catalog/RemoteAlbumDetailScreenTest.kt new file mode 100644 index 0000000..dba0e28 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/server/catalog/RemoteAlbumDetailScreenTest.kt @@ -0,0 +1,112 @@ +package app.waveflow.ui.server.catalog + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.waveflow.model.RemoteAlbum +import app.waveflow.model.RemoteAlbumDetail +import app.waveflow.model.RemoteSong +import app.waveflow.testing.remoteSong +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 + +/** L'écran d'un album distant : ce qu'il affiche et ce qu'il déclenche. */ +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w411dp-h2000dp-xhdpi") +class RemoteAlbumDetailScreenTest { + + @get:Rule + val compose = createComposeRule() + + private val songs = listOf( + remoteSong(id = "a", title = "Première Lueur", trackNumber = 1), + remoteSong(id = "b", title = "Ciel Bas", trackNumber = 2), + ) + + private val detail = RemoteAlbumDetail( + album = RemoteAlbum( + id = "album-1", + title = "Nuit Blanche", + artist = "Aurore", + artistId = "artiste-1", + year = 2024, + artworkUri = null, + ), + songs = songs, + ) + + private val joues = mutableListOf() + private var lectures = 0 + private var aleatoires = 0 + + private fun afficher( + state: AlbumDetailState, + nowPlayingMediaId: String? = null, + ) { + compose.setContent { + RemoteAlbumDetailScreen( + state = state, + nowPlayingMediaId = nowPlayingMediaId, + onSongClick = { joues += it }, + onPlay = { lectures++ }, + onShuffle = { aleatoires++ }, + onRetry = {}, + ) + } + } + + @Test + fun `l'album affiche ses commandes et ses morceaux`() { + afficher(AlbumDetailState(value = detail)) + + compose.onNodeWithText("Nuit Blanche").assertIsDisplayed() + compose.onNodeWithText("Lecture").assertIsEnabled() + compose.onNodeWithText("Aléatoire").assertIsEnabled() + compose.onNodeWithText("Première Lueur").assertIsDisplayed() + } + + @Test + fun `Lecture et Aleatoire sont deux commandes distinctes`() { + // Le piège déjà rencontré sur les albums locaux : les deux boutons + // avaient fini par faire la même chose. + afficher(AlbumDetailState(value = detail)) + + compose.onNodeWithText("Lecture").performClick() + compose.onNodeWithText("Aléatoire").performClick() + + assertEquals(1, lectures) + assertEquals(1, aleatoires) + } + + @Test + fun `un album vide n'offre pas de lecture`() { + afficher(AlbumDetailState(value = detail.copy(songs = emptyList()))) + + compose.onNodeWithText("Lecture").assertIsNotEnabled() + } + + @Test + fun `toucher un morceau le demande a l'appelant`() { + afficher(AlbumDetailState(value = detail)) + + compose.onNodeWithText("Ciel Bas").performClick() + + assertEquals(listOf(songs[1]), joues) + } + + @Test + fun `un chargement en cours n'affiche ni commande ni morceau`() { + afficher(AlbumDetailState(isLoading = true)) + + assertTrue(compose.onAllNodes(hasText("Lecture")).fetchSemanticsNodes().isEmpty()) + } +} From 913e0ac126820056377a2fcaa26c0cf6e04b4200 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 13 Aug 2026 11:03:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?test(serveur):=20l'al=C3=A9atoire=20distant?= =?UTF-8?q?=20ne=20pose=20pas=20aussi=20une=20file=20ordonn=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le test vérifiait que la file locale restait intacte, mais pas que la lecture ordonnée distante ne partait pas en plus. Une lecture ajoutée par mégarde à côté de l'aléatoire poserait la file une seconde fois et l'emporterait : le bouton Aléatoire deviendrait sans effet, et rien ne l'aurait signalé. C'est le motif du test voisin, qui vérifie déjà la collection complémentaire. Confirmé par ajout d'un `playRemote` parasite : le test tombe. Claude-Session: https://claude.ai/code/session_01F89rkrDB9TxcwHbfgNoyY1 --- .../test/java/app/waveflow/ui/player/PlayerViewModelTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt index 10ecdea..14ab704 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -92,6 +92,9 @@ class PlayerViewModelTest { assertEquals(listOf(remoteSongs), controller.playRemoteShuffledCalls) assertTrue("la file locale ne doit pas être touchée", controller.playShuffledCalls.isEmpty()) + // Ni lecture ordonnée en plus : elle poserait la file une seconde fois + // et l'emporterait, rendant le bouton Aléatoire sans effet. + assertTrue("aucune lecture ordonnée distante", controller.playRemoteCalls.isEmpty()) } @Test