diff --git a/README.md b/README.md index 5b692b5..739a1a6 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ Native Android client for [WaveFlow](https://github.com/InstaZDLL/WaveFlow) — local-first music player. Kotlin + Jetpack Compose + Media3. > **Status:** local-first. Plays, browses, searches and organises the device's -> own files. A WaveFlow server can be signed into and its catalogue browsed; -> nothing streams from it yet — see [Server](#server). +> own files, and streams from a WaveFlow server — see [Server](#server). ## Stack @@ -47,7 +46,9 @@ app/src/main/java/app/waveflow/ │ ├─ PlaybackService.kt Media3 MediaSessionService (ExoPlayer) │ ├─ PlaybackController.kt Playback facade + PlaybackState │ ├─ Media3PlaybackController.kt MediaController connection → StateFlow -│ └─ MediaItemMapper.kt Song ↔ MediaItem +│ ├─ MediaItemMapper.kt Song / RemoteSong → MediaItem, and back +│ ├─ PlayingTrack.kt What the player holds, whatever its source +│ └─ RemoteStreamResolver.kt Marker URI → ticketed stream URL └─ ui/ ├─ theme/ Material 3 emerald theme ├─ DurationFormat.kt m:ss / h:mm:ss @@ -123,7 +124,7 @@ in-memory SQLite for Room, so the DAO is exercised without a device. |---|---| | `PlaylistDaoTest` | duplicate adds, `updatedAt` bumping, positions, `reorder` normalisation, `createWithSong` atomicity, cascade delete | | `LibraryStoreTest` | loading, read failures, single subscription, retry | -| `PlayerViewModelTest` | contextual play queue, current-song resolution, controller release | +| `PlayerViewModelTest` | contextual play queue, local vs remote queue, controller release | | `PlaylistsViewModelTest` | flow failures, write failures, resolution order, atomic creation, reorder rollback and staleness | | `DragStateTest` | drag arithmetic: target rank, visual offset, bounds, `moved` | | `PlaylistDetailScreenTest` | reorder accessibility actions, order restored after a failed write | @@ -138,6 +139,8 @@ in-memory SQLite for Room, so the DAO is exercised without a device. | `HttpCatalogApiTest` | paging params, flattened details, track ordering | | `CatalogRepositoryTest` | token plumbing, retry after a refused token | | `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 | Fakes and the `Dispatchers.Main` rule live in `src/test/java/app/waveflow/testing/`. @@ -157,7 +160,7 @@ into `DragState` and tested there instead. - [x] Compose UI tests (Robolectric, no device) - [x] Sign in to a WaveFlow server (session, refresh, sign-out) - [x] Browse the server catalogue (albums, artists, paginated) -- [ ] Stream from the server +- [x] Stream from the server (ticketed URLs, seeking) - [ ] Server user-data sync (playlists, favorites, ratings) — see below - [ ] Android Auto (Media3 `MediaLibraryService`) @@ -165,9 +168,8 @@ 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. Playback -is not wired up yet, so tapping a remote track does nothing. Nothing of the -local library is sent anywhere. +and browses its catalogue — albums, artists, and what each contains. Tapping a +remote track plays it. 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` / @@ -175,6 +177,14 @@ a filter over the existing screens, and `RemoteAlbum` / `RemoteArtist` / UUIDs rather than `MediaStore` integers, and nothing can currently say that a remote track is the same file as a local one. +Playback goes through a **stream ticket**: `POST /tracks/{id}/stream-ticket` +returns a URL that needs no `Authorization` header, which is what lets ExoPlayer +consume it directly — range requests for seeking included. The ticket is minted +when the player opens the track, not when the queue is built: it lives an hour, +and a long queue would outlast it before reaching its last tracks. A +`ResolvingDataSource` does the swap, so local files and remote tracks share one +player and one queue mechanism. + Listing endpoints return a bare array — no total, no cursor — so the end of a list is inferred from a page shorter than requested. Cover art is not shown: the v2 API exposes an `artwork_hash` but no endpoint serving the image; only diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 8ae7961..11d795d 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -142,8 +142,8 @@ private fun WaveFlowRoot() { searchViewModel.clear() } - val nowPlayingId = playerState.song?.id - val hasTrack = playerState.song != null + val nowPlayingId = playerState.track?.localSongId + val hasTrack = playerState.track != null // Les écritures de playlist qui échouent se signalent une fois, sans // remplacer le contenu de l'écran. @@ -443,6 +443,14 @@ private fun WaveFlowRoot() { RemoteAlbumDetailScreen( state = detail, + nowPlayingMediaId = playerState.track?.mediaId, + // La file de lecture est l'album affiché, comme + // pour un album local. + onSongClick = { song -> + detail.value?.songs?.let { + playerViewModel.playRemoteFrom(it, song) + } + }, onRetry = { catalogViewModel.openAlbum(albumId) }, bottomPadding = listBottomPadding, ) diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt index 71405f5..d89fe18 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -35,6 +35,16 @@ interface CatalogApi { /** `GET /api/v2/artists/{id}` : l'artiste et ses albums. */ suspend fun artist(serverUrl: String, accessToken: String, artistId: String): RemoteArtistDetail + + /** + * `POST /api/v2/tracks/{id}/stream-ticket`. + * + * Rend une URL de diffusion **absolue**, qui ne demande aucun en-tête + * d'autorisation — le serveur la rend relative, elle est résolue ici contre + * [serverUrl]. C'est ce qui permet de la confier telle quelle à ExoPlayer, + * y compris pour les requêtes de plage d'un déplacement dans le morceau. + */ + suspend fun streamTicket(serverUrl: String, accessToken: String, trackId: String): String } /** diff --git a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt index 7f5c930..1920e23 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -29,6 +29,16 @@ class CatalogRepository( suspend fun artist(artistId: String): RemoteArtistDetail = authorized { url, token -> api.artist(url, token, artistId) } + /** + * URL de diffusion d'une piste, valable une heure côté serveur. + * + * Demandée au moment de lire, et non à la constitution de la file : une + * longue file dépasserait l'échéance avant d'atteindre ses derniers + * morceaux. + */ + suspend fun streamUrl(trackId: String): String = + authorized { url, token -> api.streamTicket(url, token, trackId) } + /** * Exécute [call] avec un jeton valide, en réessayant une fois sur refus. * diff --git a/app/src/main/java/app/waveflow/data/remote/Dto.kt b/app/src/main/java/app/waveflow/data/remote/Dto.kt index 0adf124..e6edb48 100644 --- a/app/src/main/java/app/waveflow/data/remote/Dto.kt +++ b/app/src/main/java/app/waveflow/data/remote/Dto.kt @@ -113,6 +113,13 @@ internal data class AlbumDetailResponse( get() = AlbumResponse(id, title, artist, artistId, year) } +/** `{"url": "/api/v2/stream/", "expires_at": }` — l'URL est relative. */ +@Serializable +internal data class StreamTicketResponse( + val url: String, + @SerialName("expires_at") val expiresAt: Long, +) + @Serializable internal data class ArtistDetailResponse( val id: String, diff --git a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt index c743a2b..5c1a9e8 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -69,6 +69,21 @@ class HttpCatalogApi( ) } + override suspend fun streamTicket( + serverUrl: String, + accessToken: String, + trackId: String, + ): String { + val ticket = http.post( + serverUrl = serverUrl, + path = "$TRACKS/$trackId/stream-ticket", + body = "{}", + accessToken = accessToken, + ).decode() + + return http.absoluteUrl(serverUrl, ticket.url) + } + private fun pageQuery(offset: Int, limit: Int) = mapOf( "offset" to offset.toString(), "limit" to limit.toString(), @@ -83,6 +98,7 @@ class HttpCatalogApi( private companion object { const val ALBUMS = "api/v2/albums" const val ARTISTS = "api/v2/artists" + const val TRACKS = "api/v2/tracks" /** Sans numéro de piste, on retombe sur le titre plutôt que sur rien. */ val BY_TRACK_THEN_TITLE = compareBy( diff --git a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt index 8a43d7b..2d60095 100644 --- a/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt +++ b/app/src/main/java/app/waveflow/data/remote/ServerHttp.kt @@ -133,6 +133,29 @@ class ServerHttp( .build() } + /** + * Résout un chemin rendu par le serveur contre l'adresse de celui-ci. + * + * Le ticket de diffusion arrive sous la forme `/api/v2/stream/`. + * Le passer à `resolve` écraserait le chemin de base : un serveur derrière + * un proxy qui le préfixe verrait son préfixe disparaître. Il est donc + * traité comme n'importe quel chemin d'API, par la même construction que + * les appels — qui, elle, conserve le préfixe. + * + * Seul un chemin absolu du serveur est accepté. Une URL complète ou une + * référence réseau (`//hôte/…`) désignerait un autre hôte que celui où + * l'utilisateur s'est authentifié. + */ + fun absoluteUrl(serverUrl: String, path: String): String { + if (!path.startsWith("/") || path.startsWith("//")) { + throw ServerException.Unexpected("Chemin de diffusion inattendu : $path") + } + + return serverUrl + .toApiUrl(path = path.removePrefix("/"), pathSegment = null, query = emptyMap()) + .toString() + } + private fun Response.toException(): ServerException { // Le serveur répond `{code, message}` sur ses erreurs métier, mais un // corps mal formé lui fait renvoyer du texte brut : lire le message diff --git a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt index e8a271c..8d4a3cf 100644 --- a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt @@ -8,6 +8,7 @@ import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope @@ -90,6 +91,16 @@ class Media3PlaybackController( ctrl.play() } + override fun playRemote(songs: List, startIndex: Int) { + val ctrl = controller ?: return + if (songs.isEmpty()) return + + ctrl.shuffleModeEnabled = false + ctrl.setMediaItems(songs.map { it.toMediaItem() }, startIndex.coerceIn(songs.indices), 0L) + ctrl.prepare() + ctrl.play() + } + override fun playShuffled(songs: List) { val ctrl = controller ?: return if (songs.isEmpty()) return @@ -145,7 +156,7 @@ class Media3PlaybackController( private fun syncFrom(player: Player) { _state.value = PlaybackState( isConnected = true, - currentSongId = player.currentMediaItem?.songId, + current = player.currentMediaItem?.toPlayingTrack(), isPlaying = player.isPlaying, positionMs = player.currentPosition.coerceAtLeast(0L), durationMs = player.duration.takeIf { it != C.TIME_UNSET }?.coerceAtLeast(0L) ?: 0L, diff --git a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt index f5efd43..07c2472 100644 --- a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt +++ b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt @@ -1,19 +1,22 @@ package app.waveflow.playback +import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import app.waveflow.model.RemoteSong import app.waveflow.model.Song /** - * Traduction [Song] <-> [MediaItem]. + * Traduction des morceaux vers [MediaItem], et retour. * - * Le `mediaId` porte l'identifiant du morceau : c'est le seul lien entre ce - * que joue Media3 et le modèle de l'application, ce qui permet de retrouver - * la piste courante sans garder de référence côté lecteur. + * Le `mediaId` est préfixé par sa source. C'est le seul lien entre ce que joue + * Media3 et le modèle de l'application ; le préfixe évite qu'un identifiant + * MediaStore et un UUID distant se confondent, et permet de reconnaître une + * piste locale sans consulter la bibliothèque. */ fun Song.toMediaItem(): MediaItem = MediaItem.Builder() - .setMediaId(id.toString()) + .setMediaId("$LOCAL_PREFIX$id") .setUri(uri) .setMediaMetadata( MediaMetadata.Builder() @@ -25,6 +28,57 @@ fun Song.toMediaItem(): MediaItem = ) .build() -/** Identifiant [Song] porté par ce [MediaItem], ou `null` s'il vient d'ailleurs. */ -val MediaItem.songId: Long? - get() = mediaId.toLongOrNull() +/** + * Piste distante, dont l'URI n'est **pas** joignable telle quelle. + * + * Le schéma `waveflow` est un marqueur : [RemoteStreamResolver] l'échange + * contre une URL de diffusion au moment où le lecteur ouvre la piste. Frapper + * le serveur ici, à la construction de la file, périmerait les tickets des + * derniers morceaux avant qu'on ne les atteigne. + */ +fun RemoteSong.toMediaItem(): MediaItem = + MediaItem.Builder() + .setMediaId("$REMOTE_PREFIX$id") + .setUri("$REMOTE_SCHEME://track/$id".toUri()) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .build(), + ) + .build() + +/** Ce que le lecteur donne à voir de sa piste courante. */ +fun MediaItem.toPlayingTrack(): PlayingTrack = PlayingTrack( + mediaId = mediaId, + title = mediaMetadata.title?.toString().orEmpty(), + artist = mediaMetadata.artist?.toString(), + album = mediaMetadata.albumTitle?.toString(), + artworkUri = mediaMetadata.artworkUri, + localSongId = localSongId, + source = if (mediaId.startsWith(REMOTE_PREFIX)) TrackSource.Remote else TrackSource.Local, +) + +/** + * Identité de cette piste distante dans la file de lecture. + * + * Permet à un écran de reconnaître la ligne en cours sans construire de + * [MediaItem] : c'est la même clé que [PlayingTrack.mediaId]. + */ +val RemoteSong.mediaId: String + get() = "$REMOTE_PREFIX$id" + +/** Identifiant MediaStore porté par ce [MediaItem], ou `null` s'il vient d'ailleurs. */ +val MediaItem.localSongId: Long? + get() = mediaId.removePrefix(LOCAL_PREFIX).takeIf { mediaId.startsWith(LOCAL_PREFIX) }?.toLongOrNull() + +/** Identifiant de piste serveur, ou `null` si la piste est locale. */ +internal fun trackIdOfRemoteUri(uri: android.net.Uri): String? = + uri.lastPathSegment?.takeIf { uri.scheme == REMOTE_SCHEME } + +private const val LOCAL_PREFIX = "local:" +private const val REMOTE_PREFIX = "remote:" + +/** Schéma interne : aucune pile réseau ne sait le résoudre, et c'est voulu. */ +internal const val REMOTE_SCHEME = "waveflow" diff --git a/app/src/main/java/app/waveflow/playback/PlaybackController.kt b/app/src/main/java/app/waveflow/playback/PlaybackController.kt index b34d628..07ae430 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackController.kt @@ -1,5 +1,6 @@ package app.waveflow.playback +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import kotlinx.coroutines.flow.StateFlow @@ -20,7 +21,8 @@ enum class RepeatMode { * * @property isConnected `true` une fois la liaison au service établie ; tant * qu'il est `false`, les commandes sont ignorées. - * @property currentSongId identifiant du morceau courant, `null` si la file est vide. + * @property current morceau courant, `null` si la file est vide. Décrit par ce + * que le lecteur en sait : il peut venir de l'appareil comme d'un serveur. * @property isPlaying lecture réellement en cours (pas seulement demandée). * @property positionMs position de lecture en millisecondes. * @property durationMs durée du morceau courant, 0 si inconnue. @@ -29,7 +31,7 @@ enum class RepeatMode { */ data class PlaybackState( val isConnected: Boolean = false, - val currentSongId: Long? = null, + val current: PlayingTrack? = null, val isPlaying: Boolean = false, val positionMs: Long = 0L, val durationMs: Long = 0L, @@ -54,6 +56,15 @@ interface PlaybackController { /** Charge [songs] comme file d'attente et démarre à [startIndex]. */ fun play(songs: List, startIndex: Int) + /** + * Même chose pour des morceaux du serveur. + * + * File distincte plutôt que mêlée à la locale : les deux sources sont + * séparées partout ailleurs dans l'app, et rien ne permet de dire qu'une + * piste distante et une piste locale sont le même enregistrement. + */ + fun playRemote(songs: List, startIndex: Int) + /** * Charge [songs] en activant la lecture aléatoire et démarre sur un * morceau au hasard. diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index e444f40..14b5196 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -3,9 +3,13 @@ package app.waveflow.playback import android.content.Intent import androidx.media3.common.AudioAttributes import androidx.media3.common.C +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService +import app.waveflow.WaveFlowApp /** * Service de lecture porté par Media3. @@ -23,7 +27,17 @@ class PlaybackService : MediaSessionService() { override fun onCreate() { super.onCreate() + // Les pistes distantes portent un marqueur `waveflow://` que rien ne sait + // ouvrir : ce résolveur l'échange contre une URL de diffusion au moment + // où le lecteur en a besoin. Les fichiers locaux traversent la même + // chaîne sans être touchés. + val dataSourceFactory = ResolvingDataSource.Factory( + DefaultDataSource.Factory(this), + RemoteStreamResolver((application as WaveFlowApp).container.catalogRepository), + ) + val player = ExoPlayer.Builder(this) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) // Route audio "musique" + gestion du focus audio (pause si un appel // arrive, etc.). .setAudioAttributes( diff --git a/app/src/main/java/app/waveflow/playback/PlayingTrack.kt b/app/src/main/java/app/waveflow/playback/PlayingTrack.kt new file mode 100644 index 0000000..31633cd --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/PlayingTrack.kt @@ -0,0 +1,30 @@ +package app.waveflow.playback + +import android.net.Uri + +/** + * Le morceau que le lecteur a réellement en main. + * + * Décrit par ce que Media3 en sait, et non par un identifiant à résoudre dans + * la bibliothèque locale : depuis que le serveur peut alimenter la file, un + * morceau en cours n'est plus forcément un fichier de l'appareil. + * + * @property mediaId identité stable dans la file, quelle que soit la source. + * @property localSongId identifiant MediaStore quand la piste est locale, `null` + * sinon. Sert à souligner la ligne en cours dans les listes locales. + * @property source d'où vient la piste, à afficher quand l'album est inconnu. + */ +data class PlayingTrack( + val mediaId: String, + val title: String, + val artist: String?, + val album: String?, + val artworkUri: Uri?, + val localSongId: Long?, + val source: TrackSource, +) + +enum class TrackSource(val label: String) { + Local("Bibliothèque locale"), + Remote("Serveur WaveFlow"), +} diff --git a/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt new file mode 100644 index 0000000..0d3f3e5 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt @@ -0,0 +1,41 @@ +package app.waveflow.playback + +import androidx.core.net.toUri +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.ResolvingDataSource +import app.waveflow.data.remote.CatalogRepository +import kotlinx.coroutines.runBlocking +import java.io.IOException + +/** + * Échange le marqueur `waveflow://track/` contre une URL de diffusion. + * + * Media3 appelle ce résolveur juste avant d'ouvrir une piste, sur son fil de + * chargement. C'est exactement le moment voulu : le ticket obtenu ne vit qu'une + * heure, et le demander à la constitution de la file périmerait celui des + * derniers morceaux avant qu'on ne les atteigne. Un déplacement dans le morceau + * rouvre la source et en redemande un, ce qui règle aussi l'expiration en cours + * d'écoute. + * + * L'appel est bloquant parce que le contrat de [ResolvingDataSource.Resolver] + * l'est ; il s'exécute hors du fil principal, sur le fil de chargement. + */ +class RemoteStreamResolver( + private val catalogRepository: CatalogRepository, +) : ResolvingDataSource.Resolver { + + override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { + val trackId = trackIdOfRemoteUri(dataSpec.uri) ?: return dataSpec + + val url = try { + runBlocking { catalogRepository.streamUrl(trackId) } + } catch (error: Exception) { + // Media3 n'attend que des IOException ici : toute autre remonterait + // brute jusqu'au lecteur et ferait tomber le service au lieu de + // signaler une piste illisible. + throw IOException("Diffusion indisponible pour la piste $trackId", error) + } + + return dataSpec.withUri(url.toUri()) + } +} diff --git a/app/src/main/java/app/waveflow/ui/components/MediaRow.kt b/app/src/main/java/app/waveflow/ui/components/MediaRow.kt index 58cb216..63b5cab 100644 --- a/app/src/main/java/app/waveflow/ui/components/MediaRow.kt +++ b/app/src/main/java/app/waveflow/ui/components/MediaRow.kt @@ -15,6 +15,7 @@ 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.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -27,6 +28,8 @@ import androidx.compose.ui.unit.dp * une destination. * * @param artworkShape ronde pour un artiste, arrondie pour un album. + * @param titleColor teintée pour signaler la ligne en cours de lecture, comme + * le fait [SongRow] dans les listes locales. * @param onClick `null` pour une ligne purement informative. Rendre le clic * facultatif plutôt que d'en passer un vide : un `Modifier.clickable` inerte * annonce quand même la ligne comme actionnable à TalkBack, et l'ondulation @@ -40,6 +43,7 @@ fun MediaRow( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, artworkShape: Shape = RoundedCornerShape(6.dp), + titleColor: Color = MaterialTheme.colorScheme.onSurface, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -58,7 +62,7 @@ fun MediaRow( Text( text = title, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = titleColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt b/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt index 0021627..d1f1835 100644 --- a/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt +++ b/app/src/main/java/app/waveflow/ui/player/MiniPlayer.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.lerp import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.waveflow.model.orUnknownArtist import app.waveflow.ui.components.Artwork /** @@ -43,8 +44,8 @@ fun MiniPlayer( onSkipNext: () -> Unit, modifier: Modifier = Modifier, ) { - val song = state.song ?: return - val accent = rememberArtworkAccent(song.artworkUri) + val track = state.track ?: return + val accent = rememberArtworkAccent(track.artworkUri) Surface( shape = RoundedCornerShape(12.dp), @@ -63,20 +64,20 @@ fun MiniPlayer( .padding(horizontal = 10.dp, vertical = 8.dp), ) { Artwork( - artworkUri = song.artworkUri, + artworkUri = track.artworkUri, modifier = Modifier.size(44.dp), ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = song.title, + text = track.title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - text = song.displayArtist, + text = track.artist.orUnknownArtist(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, 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 e63c1c1..3bacb5b 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -48,7 +48,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.waveflow.model.Song +import app.waveflow.model.orUnknownArtist +import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode import app.waveflow.ui.components.Artwork import app.waveflow.ui.formatDuration @@ -75,13 +76,13 @@ fun NowPlayingScreen( // 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. - var lastKnownSong by remember { mutableStateOf(state.song) } - LaunchedEffect(state.song) { - state.song?.let { lastKnownSong = it } + var lastKnownTrack by remember { mutableStateOf(state.track) } + LaunchedEffect(state.track) { + state.track?.let { lastKnownTrack = it } } - val song = state.song ?: lastKnownSong ?: return - val accent = rememberArtworkAccent(song.artworkUri) + val track = state.track ?: lastKnownTrack ?: return + val accent = rememberArtworkAccent(track.artworkUri) Box( modifier = modifier @@ -97,12 +98,12 @@ fun NowPlayingScreen( .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 24.dp), ) { - PlayerHeader(song = song, onCollapse = onCollapse) + PlayerHeader(track = track, onCollapse = onCollapse) Spacer(Modifier.weight(1f)) Artwork( - artworkUri = song.artworkUri, + artworkUri = track.artworkUri, shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() @@ -112,14 +113,14 @@ fun NowPlayingScreen( Spacer(Modifier.weight(1f)) - TrackTitle(song = song) + TrackTitle(track = track) Spacer(Modifier.height(16.dp)) SeekBar( positionMs = state.positionMs, durationMs = state.durationMs, - trackKey = song.id, + trackKey = track.mediaId, onSeek = onSeek, ) @@ -142,7 +143,7 @@ fun NowPlayingScreen( } @Composable -private fun PlayerHeader(song: Song, onCollapse: () -> Unit) { +private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -165,7 +166,7 @@ private fun PlayerHeader(song: Song, onCollapse: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = song.album ?: "Bibliothèque locale", + text = track.album ?: track.source.label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -179,10 +180,10 @@ private fun PlayerHeader(song: Song, onCollapse: () -> Unit) { } @Composable -private fun TrackTitle(song: Song) { +private fun TrackTitle(track: PlayingTrack) { Column(modifier = Modifier.fillMaxWidth()) { Text( - text = song.title, + text = track.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -191,7 +192,7 @@ private fun TrackTitle(song: Song) { ) Spacer(Modifier.height(4.dp)) Text( - text = song.displayArtist, + text = track.artist.orUnknownArtist(), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -204,7 +205,7 @@ private fun TrackTitle(song: Song) { private fun SeekBar( positionMs: Long, durationMs: Long, - trackKey: Long, + trackKey: String, onSeek: (Long) -> Unit, ) { // Pendant un glissement, la position affichée suit le doigt et non le 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 fccd7ef..edea798 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -1,16 +1,17 @@ package app.waveflow.ui.player -import app.waveflow.model.Song +import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode /** * État du lecteur, partagé par le mini-player et l'écran plein écran. * - * @property song morceau courant, `null` quand rien n'est chargé — dans ce cas - * le lecteur ne s'affiche pas du tout. + * @property track morceau courant, `null` quand rien n'est chargé — dans ce cas + * le lecteur ne s'affiche pas du tout. Décrit par le lecteur lui-même et non + * résolu dans la bibliothèque : il peut venir d'un serveur. */ data class PlayerUiState( - val song: Song? = null, + val track: PlayingTrack? = null, val isPlaying: Boolean = false, val positionMs: Long = 0L, val durationMs: Long = 0L, 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 c97b3a1..d04c944 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -6,12 +6,12 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import app.waveflow.WaveFlowApp -import app.waveflow.data.LibraryStore +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn /** @@ -21,16 +21,17 @@ import kotlinx.coroutines.flow.stateIn * vivante empêcherait le service de s'arrêter. */ class PlayerViewModel( - libraryStore: LibraryStore, private val playbackController: PlaybackController, ) : ViewModel() { + // Plus de croisement avec la bibliothèque : le lecteur décrit lui-même sa + // piste, ce qui vaut aussi pour celles du serveur, absentes du MediaStore. + // Cette projection est réévaluée à chaque tic de position — la garder sans + // recherche est ce qui la rend gratuite. val state: StateFlow = - combine(libraryStore.library, playbackController.state) { library, playback -> + playbackController.state.map { playback -> PlayerUiState( - // Index plutôt que parcours : cette combinaison est réévaluée à - // chaque tic de position. - song = playback.currentSongId?.let { library.songsById[it] }, + track = playback.current, isPlaying = playback.isPlaying, positionMs = playback.positionMs, durationMs = playback.durationMs, @@ -63,6 +64,23 @@ class PlayerViewModel( fun playShuffled(queue: List) = playbackController.playShuffled(queue) + /** + * Démarre [song] depuis une file de morceaux du serveur. + * + * Chemin distinct de [playFrom] : les deux catalogues ne partagent ni type + * ni identifiant, et la file remplace l'autre plutôt que de s'y mêler. + */ + fun playRemoteFrom(queue: List, song: RemoteSong) { + val startIndex = queue.indexOfFirst { it.id == song.id } + if (startIndex < 0) return + playbackController.playRemote(queue, startIndex) + } + + /** Démarre [queue] distante par son premier morceau. */ + fun playRemoteFirst(queue: List) { + queue.firstOrNull()?.let { playRemoteFrom(queue, it) } + } + fun togglePlayPause() = playbackController.playPause() fun skipNext() = playbackController.skipNext() @@ -88,7 +106,6 @@ class PlayerViewModel( initializer { val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as WaveFlowApp PlayerViewModel( - libraryStore = app.container.libraryStore, playbackController = app.container.createPlaybackController(), ) } 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 587d709..48aeb74 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,6 +18,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import app.waveflow.model.RemoteAlbum import app.waveflow.model.RemoteSong +import app.waveflow.playback.mediaId import app.waveflow.model.orUnknownArtist import app.waveflow.ui.albumCountLabel import app.waveflow.ui.components.CenteredMessage @@ -28,6 +30,8 @@ import app.waveflow.ui.trackCountLabel @Composable fun RemoteAlbumDetailScreen( state: AlbumDetailState, + nowPlayingMediaId: String?, + onSongClick: (RemoteSong) -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp, @@ -49,7 +53,11 @@ fun RemoteAlbumDetailScreen( } items(detail.songs, key = { it.id }) { song -> - RemoteSongRow(song = song) + RemoteSongRow( + song = song, + isCurrent = song.mediaId == nowPlayingMediaId, + onClick = { onSongClick(song) }, + ) } } } @@ -147,7 +155,11 @@ private fun RemoteDetailHeader( } @Composable -private fun RemoteSongRow(song: RemoteSong) { +private fun RemoteSongRow( + song: RemoteSong, + isCurrent: Boolean, + onClick: () -> Unit, +) { MediaRow( artworkUri = null, title = song.title, @@ -155,8 +167,11 @@ private fun RemoteSongRow(song: RemoteSong) { song.artist?.takeIf { it.isNotBlank() }, formatDuration(song.durationMs), ).joinToString(" · "), - // Sans `onClick` : la lecture distante arrive à l'étape suivante, et une - // ligne annoncée cliquable qui ne fait rien vaut moins qu'une ligne - // simplement informative. + onClick = onClick, + titleColor = if (isCurrent) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, ) } diff --git a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt index 0725186..33d226a 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -122,6 +122,61 @@ class HttpCatalogApiTest { ) } + @Test + fun `le ticket de diffusion devient une URL absolue`() = runTest { + server.enqueue(MockResponse().setBody(TICKET_BODY)) + + val streamUrl = api.streamTicket(url(), "wfa_1", "c07f8d98") + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/v2/tracks/c07f8d98/stream-ticket", request.path) + assertEquals("Bearer wfa_1", request.getHeader("Authorization")) + + assertEquals("${url()}/api/v2/stream/VkdLrczM", streamUrl) + } + + @Test + fun `le prefixe de proxy est conserve dans l'URL de diffusion`() = runTest { + // Le serveur rend `/api/v2/stream/…` sans savoir qu'un proxy le préfixe. + // Résoudre ce chemin contre la racine effacerait le préfixe, et l'URL + // n'atteindrait plus le serveur. + server.enqueue(MockResponse().setBody(TICKET_BODY)) + + val streamUrl = api.streamTicket("${url()}/musique", "wfa_1", "c07f8d98") + + assertEquals("${url()}/musique/api/v2/stream/VkdLrczM", streamUrl) + } + + @Test + fun `un ticket en reference reseau est refuse`() = runTest { + // `//hôte/…` emprunte le schéma courant et change d'hôte sans en avoir + // l'air : la lecture partirait ailleurs que sur le serveur où + // l'utilisateur s'est authentifié. + server.enqueue( + MockResponse().setBody("""{"url":"//ailleurs.test/api/v2/stream/x","expires_at":0}"""), + ) + + val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98") } + + assertTrue(error.toString(), error is ServerException.Unexpected) + } + + @Test + fun `un ticket en URL complete est refuse`() = runTest { + // Même conclusion par un autre chemin : le contrat est un chemin du + // serveur, pas une URL qu'il choisirait librement. + server.enqueue( + MockResponse().setBody( + """{"url":"https://ailleurs.test/api/v2/stream/x","expires_at":0}""", + ), + ) + + val error = echecDe { api.streamTicket(url(), "wfa_1", "c07f8d98") } + + assertTrue(error.toString(), error is ServerException.Unexpected) + } + @Test fun `un jeton refuse remonte comme tel`() = runTest { server.enqueue( @@ -253,6 +308,11 @@ class HttpCatalogApiTest { } """.trimIndent() + /** L'URL est relative au serveur, c'est ce que rend `stream-ticket`. */ + val TICKET_BODY = """ + {"url": "/api/v2/stream/VkdLrczM", "expires_at": 1786395364096} + """.trimIndent() + val ARTIST_DETAIL_BODY = """ { "id": "f7ba66f7-dfae-4e86-b1e1-f356f4c092b7", diff --git a/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt new file mode 100644 index 0000000..11f086b --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt @@ -0,0 +1,89 @@ +package app.waveflow.playback + +import androidx.core.net.toUri +import app.waveflow.testing.remoteSong +import app.waveflow.testing.song +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * La traduction vers Media3, et surtout la distinction des sources. + * + * Robolectric pour disposer d'un vrai `android.net.Uri`. + */ +@RunWith(RobolectricTestRunner::class) +class MediaItemMapperTest { + + @Test + fun `une piste locale garde son identifiant MediaStore`() { + val item = song(id = 42L, title = "Ciel Bas").toMediaItem() + + assertEquals(42L, item.localSongId) + assertEquals(TrackSource.Local, item.toPlayingTrack().source) + assertEquals("Ciel Bas", item.toPlayingTrack().title) + } + + @Test + fun `une piste distante n'est pas prise pour une piste locale`() { + // Sans préfixe de source, un UUID rendrait `null` à la conversion en + // Long — indiscernable d'une piste locale non résolue. + val item = remoteSong(id = "c07f8d98").toMediaItem() + + assertNull(item.localSongId) + assertEquals(TrackSource.Remote, item.toPlayingTrack().source) + } + + @Test + fun `un identifiant distant purement numerique reste distant`() { + // Le cas qui piège : `"1234"` se convertit en Long sans erreur. + val item = remoteSong(id = "1234").toMediaItem() + + assertNull(item.localSongId) + assertEquals(TrackSource.Remote, item.toPlayingTrack().source) + } + + @Test + fun `l'URI d'une piste distante n'est pas joignable telle quelle`() { + // C'est un marqueur : le frapper au moment de bâtir la file périmerait + // les tickets des derniers morceaux avant qu'on ne les atteigne. + val item = remoteSong(id = "c07f8d98").toMediaItem() + + val uri = item.localConfiguration?.uri + assertEquals("waveflow", uri?.scheme) + assertEquals("c07f8d98", uri?.let { trackIdOfRemoteUri(it) }) + } + + @Test + fun `une URI locale n'est pas prise pour une piste a resoudre`() { + val uri = "content://media/external/audio/media/42".toUri() + + assertNull(trackIdOfRemoteUri(uri)) + } + + @Test + fun `l'identite d'une piste distante est la meme des deux cotes`() { + // L'écran s'en sert pour souligner la ligne en cours sans construire de + // MediaItem : les deux doivent coïncider. + val remote = remoteSong(id = "c07f8d98") + + assertEquals(remote.mediaId, remote.toMediaItem().toPlayingTrack().mediaId) + } + + @Test + fun `les metadonnees d'une piste distante suivent jusqu'au lecteur`() { + val track = remoteSong(id = "a", title = "Résonance", artist = "Bruit de Fond", album = "Écho") + .toMediaItem() + .toPlayingTrack() + + assertEquals("Résonance", track.title) + assertEquals("Bruit de Fond", track.artist) + assertEquals("Écho", track.album) + // Le catalogue v2 n'expose aucun point d'accès aux pochettes. + assertNull(track.artworkUri) + assertTrue(track.source == TrackSource.Remote) + } +} diff --git a/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt new file mode 100644 index 0000000..dec0700 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt @@ -0,0 +1,104 @@ +package app.waveflow.playback + +import androidx.core.net.toUri +import androidx.media3.datasource.DataSpec +import app.waveflow.data.remote.CatalogRepository +import app.waveflow.data.remote.ServerException +import app.waveflow.data.remote.ServerSessionRepository +import app.waveflow.model.ServerSession +import app.waveflow.testing.FakeCatalogApi +import app.waveflow.testing.FakeServerApi +import app.waveflow.testing.FakeSessionStore +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.IOException + +/** + * L'échange du marqueur contre une URL de diffusion. + * + * Media3 appelle ce résolveur de façon bloquante, sur son fil de chargement : + * les tests l'appellent donc directement, sans coroutine. + */ +@RunWith(RobolectricTestRunner::class) +class RemoteStreamResolverTest { + + private val session = ServerSession.Connected( + serverUrl = "https://musique.test", + username = "admin", + accessToken = "wfa_1", + refreshToken = "wfr_1", + deviceId = "appareil-1", + accessExpiresAtMs = Long.MAX_VALUE, + ) + + private suspend fun resolver(catalog: FakeCatalogApi): RemoteStreamResolver { + val sessions = ServerSessionRepository( + api = FakeServerApi(), + store = FakeSessionStore(stored = session), + deviceName = "Pixel de test", + now = { 0L }, + ) + sessions.restore() + return RemoteStreamResolver(CatalogRepository(catalog, sessions)) + } + + private fun specOf(uri: String) = DataSpec(uri.toUri()) + + @Test + fun `un marqueur distant devient une URL de diffusion`() = runTest { + val catalog = FakeCatalogApi() + val resolved = resolver(catalog).resolveDataSpec(specOf("waveflow://track/c07f8d98")) + + assertEquals( + "https://musique.test/api/v2/stream/ticket-c07f8d98", + resolved.uri.toString(), + ) + } + + @Test + fun `une piste locale traverse le resolveur sans etre touchee`() = runTest { + // Les fichiers de l'appareil passent par la même chaîne : leur laisser + // leur URI est ce qui permet de n'avoir qu'un lecteur. + val catalog = FakeCatalogApi() + val spec = specOf("content://media/external/audio/media/42") + + val resolved = resolver(catalog).resolveDataSpec(spec) + + assertEquals(spec.uri, resolved.uri) + assertEquals("aucun appel au serveur", 0, catalog.calls) + } + + @Test + fun `le reste du DataSpec est preserve`() = runTest { + // La position et la longueur portent la reprise après un déplacement : + // les perdre relancerait le morceau depuis le début. + val spec = DataSpec.Builder() + .setUri("waveflow://track/abc".toUri()) + .setPosition(4_096L) + .setLength(1_024L) + .build() + + val resolved = resolver(FakeCatalogApi()).resolveDataSpec(spec) + + assertEquals(4_096L, resolved.position) + assertEquals(1_024L, resolved.length) + } + + @Test + fun `un echec du serveur devient une IOException`() = runTest { + // Media3 n'attend que ça ici : toute autre exception remonterait brute + // jusqu'au lecteur et ferait tomber le service. + val catalog = FakeCatalogApi(failure = ServerException.Unreachable("coupure")) + + val error = runCatching { + resolver(catalog).resolveDataSpec(specOf("waveflow://track/abc")) + }.exceptionOrNull() + + assertTrue(error.toString(), error is IOException) + assertTrue(error?.cause is ServerException.Unreachable) + } +} diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index 0757ad8..d233adb 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -5,6 +5,7 @@ import app.waveflow.data.MusicRepository import app.waveflow.data.PlaylistRepository import app.waveflow.model.Playlist import app.waveflow.model.PlaylistEntry +import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackState @@ -41,6 +42,25 @@ fun song( artworkUri = null, ) +/** Fabrique de morceaux distants pour les tests. */ +fun remoteSong( + id: String, + title: String = "Titre $id", + artist: String? = "Artiste $id", + album: String? = "Album $id", + albumId: String? = "album-$id", + trackNumber: Int? = 1, + durationMs: Long = 60_000L, +): RemoteSong = RemoteSong( + id = id, + title = title, + album = album, + albumId = albumId, + artist = artist, + trackNumber = trackNumber, + durationMs = durationMs, +) + class FakeMusicRepository( private val songs: Flow> = flowOf(emptyList()), ) : MusicRepository { @@ -120,6 +140,7 @@ class FakePlaybackController : PlaybackController { var released = false private set val playCalls = mutableListOf, Int>>() + val playRemoteCalls = mutableListOf, Int>>() val playShuffledCalls = mutableListOf>() override fun connect() { @@ -131,6 +152,10 @@ class FakePlaybackController : PlaybackController { playCalls += songs to startIndex } + override fun playRemote(songs: List, startIndex: Int) { + playRemoteCalls += songs to startIndex + } + override fun playShuffled(songs: List) { playShuffledCalls += songs } diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 592d545..dbde707 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -151,6 +151,15 @@ class FakeCatalogApi( ) } + override suspend fun streamTicket( + serverUrl: String, + accessToken: String, + trackId: String, + ): String { + record(serverUrl, accessToken, null) + return "$serverUrl/api/v2/stream/ticket-$trackId" + } + private fun record(serverUrl: String, accessToken: String, page: Pair?) { calls++ lastServerUrl = serverUrl @@ -249,6 +258,12 @@ class PagingCatalogApi( ) } + override suspend fun streamTicket( + serverUrl: String, + accessToken: String, + trackId: String, + ): String = "$serverUrl/api/v2/stream/ticket-$trackId" + private fun failIfDue(call: Int) { if (failFromCall > 0 && call >= failFromCall) { throw ServerException.Unreachable("coupure") 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 7334e75..dd459b9 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -4,20 +4,20 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import app.waveflow.data.LibraryStore import app.waveflow.playback.PlaybackState -import app.waveflow.testing.FakeMusicRepository +import app.waveflow.playback.PlayingTrack +import app.waveflow.playback.TrackSource import app.waveflow.testing.FakePlaybackController import app.waveflow.testing.MainDispatcherRule +import app.waveflow.testing.remoteSong import app.waveflow.testing.song -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -32,15 +32,12 @@ class PlayerViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val songs = listOf(song(id = 1L), song(id = 2L), song(id = 3L)) + private val remoteSongs = listOf(remoteSong("a"), remoteSong("b"), remoteSong("c")) private val controller = FakePlaybackController() - private fun CoroutineScope.loadedStore(): LibraryStore = - LibraryStore(FakeMusicRepository(flowOf(songs)), this).also { it.load() } - @Test fun `jouer un morceau met la file demandee et non toute la bibliotheque`() = runTest { - val viewModel = PlayerViewModel(backgroundScope.loadedStore(), controller) - advanceUntilIdle() + val viewModel = PlayerViewModel(controller) val albumQueue = songs.take(2) viewModel.playFrom(albumQueue, songs[1]) @@ -50,8 +47,7 @@ class PlayerViewModelTest { @Test fun `jouer un morceau absent de la file ne declenche rien`() = runTest { - val viewModel = PlayerViewModel(backgroundScope.loadedStore(), controller) - advanceUntilIdle() + val viewModel = PlayerViewModel(controller) viewModel.playFrom(songs.take(2), song(id = 99L)) @@ -60,8 +56,7 @@ class PlayerViewModelTest { @Test fun `playFirst sur une file vide ne declenche rien`() = runTest { - val viewModel = PlayerViewModel(backgroundScope.loadedStore(), controller) - advanceUntilIdle() + val viewModel = PlayerViewModel(controller) viewModel.playFirst(emptyList()) @@ -69,20 +64,57 @@ class PlayerViewModelTest { } @Test - fun `l'etat resout le morceau courant depuis la bibliotheque`() = runTest { - val store = backgroundScope.loadedStore() - val viewModel = PlayerViewModel(store, controller) - advanceUntilIdle() + fun `jouer un morceau distant passe par la file distante`() = runTest { + // Chemin distinct : les deux catalogues ne partagent ni type ni + // identifiant, et la file distante remplace la locale. + val viewModel = PlayerViewModel(controller) + + viewModel.playRemoteFrom(remoteSongs, remoteSongs[2]) + + assertEquals(listOf(remoteSongs to 2), controller.playRemoteCalls) + assertTrue("la file locale ne doit pas être touchée", controller.playCalls.isEmpty()) + } + + @Test + fun `jouer un morceau distant absent de la file ne declenche rien`() = runTest { + val viewModel = PlayerViewModel(controller) + + viewModel.playRemoteFrom(remoteSongs, remoteSong("inconnu")) + + assertTrue(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 + // figure pas, et la chercher ne rendrait rien à afficher. + val viewModel = PlayerViewModel(controller) val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } controller.emit( - PlaybackState(isConnected = true, currentSongId = 2L, isPlaying = true, durationMs = 60_000L), + PlaybackState( + isConnected = true, + current = PlayingTrack( + mediaId = "remote:a", + title = "Résonance", + artist = "Bruit de Fond", + album = "Écho", + artworkUri = null, + localSongId = null, + source = TrackSource.Remote, + ), + isPlaying = true, + durationMs = 60_000L, + ), ) val state = viewModel.state.value - assertEquals(2L, state.song?.id) + assertEquals("Résonance", state.track?.title) + assertEquals(TrackSource.Remote, state.track?.source) + // Rien à souligner dans les listes locales pour une piste distante. + assertNull(state.track?.localSongId) assertEquals(true, state.isPlaying) job.cancel() @@ -90,13 +122,12 @@ class PlayerViewModelTest { @Test fun `le controleur est libere avec le ViewModel`() = runTest { - val store = backgroundScope.loadedStore() // On passe par un vrai ViewModelStore pour déclencher onCleared comme // le ferait la destruction de l'écran. val viewModelStore = ViewModelStore() val provider = ViewModelProvider( viewModelStore, - viewModelFactory { initializer { PlayerViewModel(store, controller) } }, + viewModelFactory { initializer { PlayerViewModel(controller) } }, ) provider[PlayerViewModel::class.java] advanceUntilIdle()