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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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` /
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ class Media3PlaybackController(
ctrl.play()
}

override fun playRemoteShuffled(songs: List<RemoteSong>) {
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()
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/app/waveflow/playback/PlaybackController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ interface PlaybackController {
*/
fun playShuffled(songs: List<Song>)

/** Même chose pour des morceaux du serveur. */
fun playRemoteShuffled(songs: List<RemoteSong>)

fun playPause()

fun skipNext()
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ class PlayerViewModel(
queue.firstOrNull()?.let { playRemoteFrom(queue, it) }
}

fun playRemoteShuffled(queue: List<RemoteSong>) =
playbackController.playRemoteShuffled(queue)

fun togglePlayPause() = playbackController.playPause()

fun skipNext() = playbackController.skipNext()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(),
)
}

Expand Down Expand Up @@ -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),
)
}
Expand Down Expand Up @@ -133,23 +142,21 @@ private fun <T> 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(),
)
Expand Down
5 changes: 5 additions & 0 deletions app/src/test/java/app/waveflow/testing/Fakes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class FakePlaybackController : PlaybackController {
val playCalls = mutableListOf<Pair<List<Song>, Int>>()
val playRemoteCalls = mutableListOf<Pair<List<RemoteSong>, Int>>()
val playShuffledCalls = mutableListOf<List<Song>>()
val playRemoteShuffledCalls = mutableListOf<List<RemoteSong>>()

override fun connect() {
connectCount++
Expand All @@ -162,6 +163,10 @@ class FakePlaybackController : PlaybackController {
playShuffledCalls += songs
}

override fun playRemoteShuffled(songs: List<RemoteSong>) {
playRemoteShuffledCalls += songs
}

override fun playPause() = Unit

override fun skipNext() = Unit
Expand Down
13 changes: 13 additions & 0 deletions app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RemoteSong>()
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())
}
}