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..14ab704 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,19 @@ 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()) + // 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 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()) + } +}