diff --git a/app/lint.xml b/app/lint.xml
index 3fe4389..1a914fe 100644
--- a/app/lint.xml
+++ b/app/lint.xml
@@ -18,4 +18,17 @@
name="opt-in"
value="androidx.media3.common.util.UnstableApi" />
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c2c1fa4..8ad8fd4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -41,13 +41,24 @@
-
+
+
+
+
+
+
+
diff --git a/app/src/main/java/app/waveflow/playback/BrowseCallback.kt b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt
new file mode 100644
index 0000000..ac90bd2
--- /dev/null
+++ b/app/src/main/java/app/waveflow/playback/BrowseCallback.kt
@@ -0,0 +1,151 @@
+package app.waveflow.playback
+
+import androidx.media3.common.MediaItem
+import androidx.media3.session.LibraryResult
+import androidx.media3.session.MediaLibraryService.LibraryParams
+import androidx.media3.session.MediaLibraryService.MediaLibrarySession
+import androidx.media3.session.MediaSession
+import androidx.media3.session.SessionError
+import com.google.common.collect.ImmutableList
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Le pont entre [BrowseTree] et ce que Media3 attend d'une bibliothèque.
+ *
+ * Séparé du service pour être éprouvable sans en démarrer un : tout ce qui suit
+ * est de la traduction, et une traduction se vérifie.
+ *
+ * Toutes les réponses sont immédiates. L'arbre lit un instantané déjà en
+ * mémoire ; rien ici ne part sur le réseau ni sur le disque, et un hôte comme
+ * Android Auto n'attend pas.
+ */
+class BrowseCallback(private val tree: BrowseTree) : MediaLibrarySession.Callback {
+
+ /**
+ * Les nœuds qu'un navigateur a demandé à suivre, au moins une fois.
+ *
+ * Media3 tient la liste des abonnés d'un nœud donné, mais ne sait pas dire
+ * quels nœuds ont un abonné : il faut donc les retenir pour savoir qui
+ * prévenir quand la bibliothèque change. C'est aussi ce qui borne le coût —
+ * notifier tous les albums d'une bibliothèque reviendrait à la parcourir une
+ * fois par album, quand un navigateur n'en regarde qu'un.
+ *
+ * Une liste de candidats, et non l'état des abonnements : c'est la session
+ * qui dit lesquels valent encore, et elle seule peut le dire. Deux
+ * navigateurs peuvent suivre le même nœud, et compter les abonnements ici
+ * reviendrait à tenir en double une comptabilité qu'elle tient déjà — un
+ * désabonnement retirerait un nœud que l'autre regarde encore.
+ *
+ * Concurrent parce que rien ne garantit que les abonnements et les mises à
+ * jour de la bibliothèque arrivent du même fil.
+ */
+ private val watched: MutableSet = ConcurrentHashMap.newKeySet()
+
+ override fun onSubscribe(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ parentId: String,
+ params: LibraryParams?,
+ ): ListenableFuture> {
+ watched += parentId
+ // Le comportement par défaut est conservé : c'est lui qui envoie au
+ // navigateur le premier état du nœud, aussitôt après l'abonnement.
+ return super.onSubscribe(session, browser, parentId, params)
+ }
+
+ /**
+ * Prévient les navigateurs que l'arbre a changé sous eux.
+ *
+ * Sans cet appel, un navigateur garde ce qu'il a lu la première fois. Le cas
+ * n'est pas marginal : Android Auto se connecte au démarrage de la voiture,
+ * avant que la bibliothèque de l'appareil ne soit lue, et resterait donc
+ * devant un arbre vide jusqu'à ce qu'on l'oblige à redemander.
+ *
+ * Les nœuds que plus personne ne regarde sont oubliés au passage. On le
+ * demande à la session plutôt que de le déduire des désabonnements : un
+ * navigateur peut disparaître sans se désabonner, et un nœud que deux
+ * navigateurs suivent reste suivi quand l'un des deux s'en va.
+ */
+ fun notifySubscribers(session: MediaLibrarySession) {
+ watched.removeAll { session.getSubscribedControllers(it).isEmpty() }
+ watched.forEach { parentId ->
+ session.notifyChildrenChanged(parentId, tree.children(parentId).size, /* params = */ null)
+ }
+ }
+
+ override fun onGetLibraryRoot(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ params: LibraryParams?,
+ ): ListenableFuture> =
+ Futures.immediateFuture(LibraryResult.ofItem(tree.root(), params))
+
+ override fun onGetChildren(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ parentId: String,
+ page: Int,
+ pageSize: Int,
+ params: LibraryParams?,
+ ): ListenableFuture>> {
+ val slice = tree.children(parentId).page(page, pageSize)
+
+ return Futures.immediateFuture(
+ LibraryResult.ofItemList(ImmutableList.copyOf(slice), params),
+ )
+ }
+
+ override fun onGetItem(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ mediaId: String,
+ ): ListenableFuture> {
+ val item = tree.item(mediaId)
+ ?: return Futures.immediateFuture(LibraryResult.ofError(SessionError.ERROR_BAD_VALUE))
+
+ return Futures.immediateFuture(LibraryResult.ofItem(item, null))
+ }
+
+ /**
+ * Donne au lecteur des éléments qu'il sait ouvrir.
+ *
+ * Ceux que l'hôte renvoie viennent de [BrowseTree.children] : ils portent un
+ * `mediaId` et des métadonnées, mais **pas d'URI** — l'hôte les affiche, il
+ * ne les lit pas. Sans cette résolution, le lecteur recevrait des éléments
+ * sans source et ne jouerait rien.
+ *
+ * Un élément qui porte déjà une URI est laissé tel quel : il vient alors de
+ * l'application elle-même, qui construit ses files complètes.
+ */
+ override fun onAddMediaItems(
+ mediaSession: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ mediaItems: MutableList,
+ ): ListenableFuture> {
+ val resolved = mediaItems.flatMap { item ->
+ if (item.localConfiguration != null) listOf(item) else tree.resolve(item.mediaId)
+ }
+
+ return Futures.immediateFuture(resolved.toMutableList())
+ }
+}
+
+/**
+ * La tranche que l'hôte réclame, bornée à ce qui existe.
+ *
+ * Une page hors bornes n'est pas une erreur : c'est la fin de la liste. Rendre
+ * un échec ferait apparaître un avertissement là où il n'y a simplement rien de
+ * plus à voir — et une tranche calculée sans borne lèverait, ce qui couperait
+ * la navigation au lieu de la terminer.
+ *
+ * Une taille de page nulle ou négative ne décrit aucune tranche : rien à rendre.
+ */
+internal fun List.page(page: Int, pageSize: Int): List {
+ if (pageSize <= 0 || page < 0) return emptyList()
+
+ val from = (page.toLong() * pageSize).coerceAtMost(size.toLong()).toInt()
+ val to = (from.toLong() + pageSize).coerceAtMost(size.toLong()).toInt()
+ return subList(from, to)
+}
diff --git a/app/src/main/java/app/waveflow/playback/BrowseTree.kt b/app/src/main/java/app/waveflow/playback/BrowseTree.kt
new file mode 100644
index 0000000..e339967
--- /dev/null
+++ b/app/src/main/java/app/waveflow/playback/BrowseTree.kt
@@ -0,0 +1,246 @@
+package app.waveflow.playback
+
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import app.waveflow.model.Library
+import app.waveflow.model.Playlist
+import app.waveflow.model.PlaylistEntry
+import app.waveflow.model.Song
+
+/**
+ * Ce qu'un hôte extérieur — Android Auto, Assistant — voit de la bibliothèque.
+ *
+ * L'arbre ne sert que la bibliothèque de l'appareil. Le catalogue du serveur en
+ * est absent à dessein : il se demande par le réseau, page par page, et une
+ * branche qui met deux secondes à répondre ou qui se vide hors couverture est
+ * pire, au volant, qu'une branche qui n'existe pas. Le local, lui, est déjà en
+ * mémoire quand la question arrive.
+ *
+ * Les identifiants de navigation portent le préfixe [BROWSE_PREFIX], que rien
+ * d'autre n'emploie : une feuille garde le `mediaId` que [toMediaItem] lui
+ * donne, si bien qu'un nœud et une piste ne peuvent pas se confondre.
+ */
+class BrowseTree(private val snapshot: () -> BrowseSnapshot) {
+
+ /**
+ * La racine, seule réponse possible tant que la bibliothèque n'est pas lue.
+ *
+ * Android Auto la demande avant tout le reste et n'attend pas : rendre ici
+ * un nœud vide au motif que le chargement n'est pas fini afficherait une
+ * voiture sans musique jusqu'au prochain balayage.
+ */
+ fun root(): MediaItem = browsableNode(ROOT_ID, "Bibliothèque")
+
+ /**
+ * Les enfants de [parentId], ou une liste vide si le nœud n'existe pas.
+ *
+ * Une liste vide est aussi ce que rend un nœud réel mais sans contenu — un
+ * album dont les fichiers ont disparu, par exemple. L'hôte ne distingue pas
+ * les deux, et n'a pas à le faire : dans les deux cas il n'y a rien à
+ * montrer.
+ */
+ fun children(parentId: String): List {
+ val state = snapshot()
+
+ state.songsUnder(parentId)?.let { songs -> return songs.map { it.toBrowsableLeaf() } }
+
+ return when (parentId) {
+ ROOT_ID -> rootSections(state)
+ ALBUMS_ID -> state.library.albums.map(::albumNode)
+ ARTISTS_ID -> state.library.artists.map(::artistNode)
+ PLAYLISTS_ID -> state.playlists.map(::playlistNode)
+ else -> emptyList()
+ }
+ }
+
+ /** Le nœud ou la feuille que désigne [mediaId], `null` s'il n'existe pas. */
+ fun item(mediaId: String): MediaItem? {
+ if (mediaId == ROOT_ID) return root()
+
+ val state = snapshot()
+ return rootSections(state).firstOrNull { it.mediaId == mediaId }
+ ?: state.library.albums.firstOrNull { albumId(it.id) == mediaId }?.let(::albumNode)
+ ?: state.library.artists.firstOrNull { artistId(it.id) == mediaId }?.let(::artistNode)
+ ?: state.playlists.firstOrNull { playlistId(it.id) == mediaId }?.let(::playlistNode)
+ ?: state.songOf(mediaId)?.toBrowsableLeaf()
+ }
+
+ /**
+ * Ce qu'il faut mettre dans le lecteur quand l'hôte demande [mediaId].
+ *
+ * Les [MediaItem] rendus par [children] n'ont **pas** d'URI : l'hôte les
+ * affiche, il ne les ouvre pas. C'est ici que la piste retrouve la sienne,
+ * par [toMediaItem] — le même chemin que l'application elle-même, donc les
+ * mêmes clés de cache et le même résolveur.
+ *
+ * Un nœud est jouable comme ses feuilles : demander un album, c'est
+ * demander ses pistes dans l'ordre.
+ */
+ fun resolve(mediaId: String): List {
+ val state = snapshot()
+
+ state.songOf(mediaId)?.let { return listOf(it.toMediaItem()) }
+
+ // Les morceaux du nœud, pas les feuilles que [children] en tirerait :
+ // repasser par elles construirait un [MediaItem] par piste pour le
+ // jeter aussitôt, et il faudrait ensuite retrouver chaque morceau à
+ // partir de son identifiant. Cet appel est synchrone — Media3 attend
+ // la file avant de rendre la main à l'hôte.
+ return state.songsUnder(mediaId).orEmpty().map { it.toMediaItem() }
+ }
+
+ private fun rootSections(state: BrowseSnapshot): List = buildList {
+ add(browsableNode(ALBUMS_ID, "Albums"))
+ add(browsableNode(ARTISTS_ID, "Artistes"))
+ // Une section de playlists vide n'apprend rien et occupe une place que
+ // la voiture n'a pas.
+ if (state.playlists.isNotEmpty()) add(browsableNode(PLAYLISTS_ID, "Playlists"))
+ add(browsableNode(SONGS_ID, "Toutes les pistes"))
+ }
+
+ private fun albumNode(album: app.waveflow.model.Album): MediaItem = browsableNode(
+ mediaId = albumId(album.id),
+ title = album.title,
+ subtitle = album.displayArtist,
+ artworkUri = album.artworkUri,
+ mediaType = MediaMetadata.MEDIA_TYPE_ALBUM,
+ )
+
+ private fun artistNode(artist: app.waveflow.model.Artist): MediaItem = browsableNode(
+ mediaId = artistId(artist.id),
+ title = artist.name,
+ artworkUri = artist.artworkUri,
+ mediaType = MediaMetadata.MEDIA_TYPE_ARTIST,
+ )
+
+ private fun playlistNode(playlist: Playlist): MediaItem = browsableNode(
+ mediaId = playlistId(playlist.id),
+ title = playlist.name,
+ mediaType = MediaMetadata.MEDIA_TYPE_PLAYLIST,
+ )
+
+ private fun browsableNode(
+ mediaId: String,
+ title: String,
+ subtitle: String? = null,
+ artworkUri: android.net.Uri? = null,
+ mediaType: Int = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED,
+ ): MediaItem = MediaItem.Builder()
+ .setMediaId(mediaId)
+ .setMediaMetadata(
+ MediaMetadata.Builder()
+ .setTitle(title)
+ .setSubtitle(subtitle)
+ .setArtworkUri(artworkUri)
+ .setIsBrowsable(true)
+ .setIsPlayable(false)
+ .setMediaType(mediaType)
+ .build(),
+ )
+ .build()
+
+ /**
+ * Une piste telle que l'hôte la liste : sans URI.
+ *
+ * Elle porte le même `mediaId` que l'élément jouable — c'est ce qui permet
+ * à [resolve] de la retrouver quand l'hôte la redemande pour la lire.
+ */
+ private fun Song.toBrowsableLeaf(): MediaItem = MediaItem.Builder()
+ .setMediaId(mediaId)
+ .setMediaMetadata(
+ MediaMetadata.Builder()
+ .setTitle(title)
+ .setArtist(artist)
+ .setAlbumTitle(album)
+ .setArtworkUri(artworkUri)
+ .setIsBrowsable(false)
+ .setIsPlayable(true)
+ .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
+ .build(),
+ )
+ .build()
+
+ /**
+ * Les morceaux que contient ce nœud, `null` s'il n'en contient pas.
+ *
+ * `null` et liste vide se distinguent : le premier dit « ce n'est pas un
+ * nœud de pistes » — la racine, la section des albums — le second « ce nœud
+ * en contient zéro ». [resolve] s'appuie sur cette différence, faute de quoi
+ * demander la section Albums mettrait toute la bibliothèque dans le lecteur.
+ */
+ private fun BrowseSnapshot.songsUnder(parentId: String): List? = when {
+ parentId == SONGS_ID -> library.songs
+ parentId.startsWith(ALBUM_PREFIX) -> songsOfAlbum(parentId.idAfter(ALBUM_PREFIX))
+ parentId.startsWith(ARTIST_PREFIX) -> songsOfArtist(parentId.idAfter(ARTIST_PREFIX))
+ parentId.startsWith(PLAYLIST_PREFIX) -> songsOfPlaylist(parentId.idAfter(PLAYLIST_PREFIX))
+ else -> null
+ }
+
+ private fun BrowseSnapshot.songsOfAlbum(id: Long?): List =
+ library.songs.filter { it.albumId == id }
+
+ private fun BrowseSnapshot.songsOfArtist(id: Long?): List =
+ library.songs.filter { it.artistId == id }
+
+ /**
+ * Les pistes d'une playlist, dans l'ordre voulu.
+ *
+ * Une entrée dont le fichier a disparu de l'appareil est ignorée : la
+ * playlist garde la mémoire d'un morceau que le MediaStore ne connaît plus.
+ */
+ private fun BrowseSnapshot.songsOfPlaylist(id: Long?): List = playlistEntries
+ .filter { it.playlistId == id }
+ .sortedBy(PlaylistEntry::position)
+ .mapNotNull { library.songsById[it.songId] }
+
+ /**
+ * Le morceau que désigne ce `mediaId` de feuille, `null` si c'en est un autre.
+ *
+ * Par l'index de la bibliothèque : l'hôte peut demander la résolution d'une
+ * section entière, et un balayage par identifiant y coûterait un parcours
+ * complet par piste.
+ */
+ private fun BrowseSnapshot.songOf(mediaId: String): Song? =
+ localSongIdOf(mediaId)?.let { library.songsById[it] }
+
+ private fun String.idAfter(prefix: String): Long? = removePrefix(prefix).toLongOrNull()
+
+ private fun albumId(id: Long) = "$ALBUM_PREFIX$id"
+
+ private fun artistId(id: Long) = "$ARTIST_PREFIX$id"
+
+ private fun playlistId(id: Long) = "$PLAYLIST_PREFIX$id"
+
+ companion object {
+ /**
+ * Préfixe des nœuds de navigation.
+ *
+ * Distinct de ceux des pistes (`local:`, `remote:`) pour qu'aucun nœud
+ * ne puisse être pris pour une piste, ni l'inverse.
+ */
+ const val BROWSE_PREFIX = "browse:"
+
+ const val ROOT_ID = "${BROWSE_PREFIX}root"
+
+ private const val ALBUMS_ID = "${BROWSE_PREFIX}albums"
+ private const val ARTISTS_ID = "${BROWSE_PREFIX}artists"
+ private const val PLAYLISTS_ID = "${BROWSE_PREFIX}playlists"
+ private const val SONGS_ID = "${BROWSE_PREFIX}songs"
+
+ private const val ALBUM_PREFIX = "${BROWSE_PREFIX}album/"
+ private const val ARTIST_PREFIX = "${BROWSE_PREFIX}artist/"
+ private const val PLAYLIST_PREFIX = "${BROWSE_PREFIX}playlist/"
+ }
+}
+
+/**
+ * L'état de la bibliothèque à l'instant où l'hôte pose sa question.
+ *
+ * Un instantané plutôt que des flux : [BrowseTree] répond de façon synchrone, et
+ * la question vient d'un processus extérieur qui n'attendra pas.
+ */
+data class BrowseSnapshot(
+ val library: Library = Library(),
+ val playlists: List = emptyList(),
+ val playlistEntries: List = emptyList(),
+)
diff --git a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt
index e5e2c6e..a549c5c 100644
--- a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt
+++ b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt
@@ -16,7 +16,7 @@ import app.waveflow.model.Song
*/
fun Song.toMediaItem(): MediaItem =
MediaItem.Builder()
- .setMediaId("$LOCAL_PREFIX$id")
+ .setMediaId(mediaId)
.setUri(uri)
.setMediaMetadata(
MediaMetadata.Builder()
@@ -71,9 +71,24 @@ fun MediaItem.toPlayingTrack(): PlayingTrack = PlayingTrack(
val RemoteSong.mediaId: String
get() = "$REMOTE_PREFIX$id"
+/** Identité de ce morceau local, sans passer par [toMediaItem]. */
+val Song.mediaId: String
+ get() = "$LOCAL_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()
+ get() = localSongIdOf(mediaId)
+
+/**
+ * Identifiant MediaStore que porte ce `mediaId`, ou `null` s'il vient d'ailleurs.
+ *
+ * La même lecture que [MediaItem.localSongId], mais sur la chaîne seule : un
+ * hôte extérieur redemande une piste par son identifiant, sans le [MediaItem]
+ * qui l'accompagnait. Retrouver le morceau en reconstruisant un [MediaItem] par
+ * candidat coûterait un balayage de la bibliothèque là où un index suffit.
+ */
+internal fun localSongIdOf(mediaId: String): Long? =
+ mediaId.takeIf { it.startsWith(LOCAL_PREFIX) }?.removePrefix(LOCAL_PREFIX)?.toLongOrNull()
/**
* Clé de cache d'une piste distante.
diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt
index c27005d..d15d578 100644
--- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt
+++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt
@@ -6,27 +6,35 @@ import androidx.media3.common.C
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
+import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
-import androidx.media3.session.MediaSessionService
import app.waveflow.WaveFlowApp
import coil.imageLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
/**
* Service de lecture porté par Media3.
*
- * Un [MediaSessionService] héberge un [ExoPlayer] + une [MediaSession] : Android
- * en tire automatiquement la notification média, les contrôles de l'écran de
- * verrouillage et la lecture en arrière-plan. L'UI se connecte via un
+ * Un [MediaLibraryService] héberge un [ExoPlayer] + une [MediaLibrarySession] :
+ * Android en tire automatiquement la notification média, les contrôles de
+ * l'écran de verrouillage et la lecture en arrière-plan. L'UI se connecte via un
* `MediaController` (voir [PlaybackController]) — elle ne parle jamais
* directement à l'ExoPlayer.
+ *
+ * C'est un [MediaLibraryService] et non un simple `MediaSessionService` parce
+ * qu'Android Auto ne se contente pas de commander la lecture : il veut parcourir
+ * la bibliothèque. Cette différence tient à l'arbre exposé par [BrowseTree] ;
+ * pour l'application, rien ne change — un `MediaLibrarySession` est une
+ * `MediaSession`.
*/
-class PlaybackService : MediaSessionService() {
+class PlaybackService : MediaLibraryService() {
- private var mediaSession: MediaSession? = null
+ private var mediaSession: MediaLibrarySession? = null
/**
* Portée des chargements de pochette : ils n'ont plus de destinataire une
@@ -34,6 +42,26 @@ class PlaybackService : MediaSessionService() {
*/
private val artworkScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ /**
+ * Ce que l'arbre de navigation donne à voir, tenu à jour en continu.
+ *
+ * `@Volatile` parce qu'il est écrit par la coroutine qui observe et lu par
+ * le fil d'où l'hôte pose ses questions. L'instantané est remplacé d'un
+ * bloc, jamais modifié en place : un lecteur voit donc toujours un état
+ * cohérent, fût-il d'un instant plus tôt.
+ */
+ @Volatile
+ private var browseSnapshot = BrowseSnapshot()
+
+ /**
+ * Ce que la session répond aux hôtes qui parcourent la bibliothèque.
+ *
+ * Tenu ici, et non construit au vol dans le constructeur de la session,
+ * parce qu'il faut pouvoir lui redemander de prévenir ses abonnés à chaque
+ * fois que [browseSnapshot] change.
+ */
+ private val browseCallback = BrowseCallback(BrowseTree { browseSnapshot })
+
override fun onCreate() {
super.onCreate()
@@ -68,12 +96,46 @@ class PlaybackService : MediaSessionService() {
// de recharger la même image à chaque rafraîchissement.
val bitmapLoader = CacheBitmapLoader(CoilBitmapLoader(this, imageLoader, artworkScope))
- mediaSession = MediaSession.Builder(this, player)
+ mediaSession = MediaLibrarySession.Builder(this, player, browseCallback)
.setBitmapLoader(bitmapLoader)
.build()
+
+ // Après la session, et pas avant : la première valeur du flux arrive
+ // sans délai, et elle a des abonnés à prévenir.
+ observeLibrary(container)
+ }
+
+ /**
+ * Tient [browseSnapshot] à jour tant que le service vit.
+ *
+ * `load()` est demandé ici parce que le service peut démarrer sans que
+ * l'application ait été ouverte — Android Auto s'y connecte directement. Si
+ * la permission audio manque, la bibliothèque restera vide et l'arbre le
+ * sera aussi : c'est le seul comportement honnête, l'hôte n'ayant aucun
+ * moyen de la demander.
+ */
+ private fun observeLibrary(container: app.waveflow.AppContainer) {
+ container.libraryStore.load()
+
+ artworkScope.launch {
+ combine(
+ container.libraryStore.library,
+ container.playlistRepository.observePlaylists(),
+ container.playlistRepository.observeEntries(),
+ ) { library, playlists, entries ->
+ BrowseSnapshot(library, playlists, entries)
+ }.collect { snapshot ->
+ browseSnapshot = snapshot
+ // L'instantané seul ne suffit pas : un navigateur déjà connecté
+ // ne redemande rien de lui-même, il attend qu'on lui dise que
+ // ce qu'il affiche a changé.
+ mediaSession?.let(browseCallback::notifySubscribers)
+ }
+ }
}
- override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? =
+ mediaSession
// Si l'app est balayée depuis les récents alors que rien ne joue, on arrête
// le service pour ne pas laisser une notification fantôme.
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..afad23a
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/test/java/app/waveflow/playback/BrowsePageTest.kt b/app/src/test/java/app/waveflow/playback/BrowsePageTest.kt
new file mode 100644
index 0000000..b2a65e6
--- /dev/null
+++ b/app/src/test/java/app/waveflow/playback/BrowsePageTest.kt
@@ -0,0 +1,63 @@
+package app.waveflow.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Le découpage en pages demandé par l'hôte.
+ *
+ * C'est la seule arithmétique du pont vers Media3, et celle où une borne
+ * oubliée ne se voit pas : `subList` lève au lieu de rendre une liste vide, et
+ * la navigation se couperait au dernier écran plutôt que de s'y terminer.
+ */
+class BrowsePageTest {
+
+ private val cinq = listOf("a", "b", "c", "d", "e")
+
+ @Test
+ fun `la premiere page rend le debut de la liste`() {
+ assertEquals(listOf("a", "b"), cinq.page(page = 0, pageSize = 2))
+ }
+
+ @Test
+ fun `la derniere page est tronquee a ce qui reste`() {
+ // Cinq éléments par pages de deux : la troisième page n'en a qu'un.
+ assertEquals(listOf("e"), cinq.page(page = 2, pageSize = 2))
+ }
+
+ @Test
+ fun `une page au-dela de la fin rend une liste vide`() {
+ // L'hôte demande la page suivante jusqu'à ce qu'elle soit vide : c'est
+ // ainsi qu'il sait qu'il a tout vu. Lever ici couperait la navigation.
+ assertEquals(emptyList(), cinq.page(page = 9, pageSize = 2))
+ }
+
+ @Test
+ fun `une page qui commence pile a la fin rend une liste vide`() {
+ // Le cas limite exact : `from` vaut la taille de la liste.
+ assertEquals(emptyList(), cinq.page(page = 1, pageSize = 5))
+ }
+
+ @Test
+ fun `une taille de page plus grande que la liste la rend entiere`() {
+ assertEquals(cinq, cinq.page(page = 0, pageSize = 100))
+ }
+
+ @Test
+ fun `une taille de page nulle ou negative ne decrit aucune tranche`() {
+ assertEquals(emptyList(), cinq.page(page = 0, pageSize = 0))
+ assertEquals(emptyList(), cinq.page(page = 0, pageSize = -1))
+ }
+
+ @Test
+ fun `un tres grand numero de page ne deborde pas`() {
+ // `page * pageSize` sur des entiers déborderait et rendrait un indice
+ // négatif, que `subList` refuserait.
+ assertEquals(emptyList(), cinq.page(page = Int.MAX_VALUE, pageSize = 1000))
+ }
+
+ @Test
+ fun `une liste vide rend une liste vide`() {
+ assertEquals(emptyList(), emptyList().page(page = 0, pageSize = 10))
+ }
+}
diff --git a/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt b/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt
new file mode 100644
index 0000000..c047d61
--- /dev/null
+++ b/app/src/test/java/app/waveflow/playback/BrowseTreeTest.kt
@@ -0,0 +1,221 @@
+package app.waveflow.playback
+
+import androidx.core.net.toUri
+import app.waveflow.model.Library
+import app.waveflow.model.Playlist
+import app.waveflow.model.PlaylistEntry
+import app.waveflow.model.Song
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * L'arbre que voit Android Auto.
+ *
+ * Ce qui se joue ici n'est pas l'affichage mais le **contrat** : un hôte
+ * extérieur pose des questions par identifiant et n'a aucun moyen de deviner ce
+ * qu'on a voulu dire. Un identifiant qui ne se retrouve pas, une feuille sans
+ * source, et la voiture reste muette sans rien signaler.
+ *
+ * Robolectric parce que `Uri` et `MediaItem` viennent du cadre Android.
+ */
+@RunWith(RobolectricTestRunner::class)
+class BrowseTreeTest {
+
+ private fun song(
+ id: Long,
+ title: String,
+ album: String = "Album",
+ albumId: Long = 10L,
+ artist: String = "Artiste",
+ artistId: Long = 100L,
+ ) = Song(
+ id = id,
+ uri = "content://media/external/audio/media/$id".toUri(),
+ title = title,
+ artist = artist,
+ artistId = artistId,
+ album = album,
+ albumId = albumId,
+ durationMs = 180_000L,
+ artworkUri = null,
+ )
+
+ private fun tree(
+ songs: List = emptyList(),
+ playlists: List = emptyList(),
+ entries: List = emptyList(),
+ ) = BrowseTree {
+ BrowseSnapshot(
+ library = Library(isLoading = false, songs = songs),
+ playlists = playlists,
+ playlistEntries = entries,
+ )
+ }
+
+ private fun titlesOf(items: List) =
+ items.map { it.mediaMetadata.title.toString() }
+
+ @Test
+ fun `la racine repond meme sans bibliotheque`() {
+ // Android Auto demande la racine avant tout le reste et n'attend pas.
+ // Rendre un échec ou rien tant que le chargement n'est pas fini
+ // laisserait la voiture sans musique jusqu'au prochain balayage.
+ val root = tree().root()
+
+ assertEquals(BrowseTree.ROOT_ID, root.mediaId)
+ assertTrue(root.mediaMetadata.isBrowsable == true)
+ assertFalse(root.mediaMetadata.isPlayable == true)
+ }
+
+ @Test
+ fun `la racine offre les sections de la bibliotheque`() {
+ val items = tree(songs = listOf(song(1, "Un"))).children(BrowseTree.ROOT_ID)
+
+ assertEquals(listOf("Albums", "Artistes", "Toutes les pistes"), titlesOf(items))
+ }
+
+ @Test
+ fun `la section des playlists n'apparait que s'il y en a`() {
+ // Une section vide n'apprend rien et occupe une place que la voiture
+ // n'a pas.
+ val avec = tree(
+ songs = listOf(song(1, "Un")),
+ playlists = listOf(Playlist(id = 7L, name = "Route")),
+ ).children(BrowseTree.ROOT_ID)
+
+ assertTrue("Playlists" in titlesOf(avec))
+ }
+
+ @Test
+ fun `un album ne rend que ses propres pistes`() {
+ val songs = listOf(
+ song(1, "Une", album = "Bleu", albumId = 10L),
+ song(2, "Deux", album = "Bleu", albumId = 10L),
+ song(3, "Trois", album = "Rouge", albumId = 20L),
+ )
+ val arbre = tree(songs)
+
+ val albums = arbre.children("${BrowseTree.BROWSE_PREFIX}albums")
+ val bleu = albums.first { it.mediaMetadata.title.toString() == "Bleu" }
+
+ assertEquals(listOf("Une", "Deux"), titlesOf(arbre.children(bleu.mediaId)))
+ }
+
+ @Test
+ fun `une playlist rend ses pistes dans l'ordre voulu`() {
+ // L'ordre d'une playlist est le sien, pas celui de la bibliothèque :
+ // les entrées portent une position, et c'est elle qui décide.
+ //
+ // Les entrées sont volontairement données **à rebours de leur
+ // position** : rangées dans le bon ordre, elles rendraient le tri
+ // invisible et ce test passerait sans lui.
+ val songs = listOf(song(1, "Une"), song(2, "Deux"), song(3, "Trois"))
+ val arbre = tree(
+ songs = songs,
+ playlists = listOf(Playlist(id = 7L, name = "Route")),
+ entries = listOf(
+ PlaylistEntry(playlistId = 7L, songId = 1L, position = 1),
+ PlaylistEntry(playlistId = 7L, songId = 3L, position = 0),
+ ),
+ )
+
+ val pistes = arbre.children("${BrowseTree.BROWSE_PREFIX}playlist/7")
+
+ assertEquals(listOf("Trois", "Une"), titlesOf(pistes))
+ }
+
+ @Test
+ fun `une entree de playlist dont le fichier a disparu est ignoree`() {
+ // Une playlist garde la mémoire d'un morceau que le MediaStore ne
+ // connaît plus. La sauter vaut mieux que rendre une entrée sans source,
+ // que le lecteur ne saurait pas ouvrir.
+ val arbre = tree(
+ songs = listOf(song(1, "Une")),
+ playlists = listOf(Playlist(id = 7L, name = "Route")),
+ entries = listOf(
+ PlaylistEntry(playlistId = 7L, songId = 1L, position = 0),
+ PlaylistEntry(playlistId = 7L, songId = 404L, position = 1),
+ ),
+ )
+
+ assertEquals(listOf("Une"), titlesOf(arbre.children("${BrowseTree.BROWSE_PREFIX}playlist/7")))
+ }
+
+ @Test
+ fun `les feuilles listees n'ont pas de source`() {
+ // L'hôte les affiche, il ne les ouvre pas. C'est `resolve` qui leur rend
+ // une URI au moment de jouer — sinon le lecteur recevrait des éléments
+ // sans source et ne jouerait rien.
+ val items = tree(songs = listOf(song(1, "Une"))).children("${BrowseTree.BROWSE_PREFIX}songs")
+
+ val feuille = items.single()
+ assertNull("une feuille de navigation ne porte pas d'URI", feuille.localConfiguration)
+ assertTrue(feuille.mediaMetadata.isPlayable == true)
+ }
+
+ @Test
+ fun `resoudre une feuille lui rend sa source`() {
+ val arbre = tree(songs = listOf(song(1, "Une")))
+ val feuille = arbre.children("${BrowseTree.BROWSE_PREFIX}songs").single()
+
+ val jouable = arbre.resolve(feuille.mediaId).single()
+
+ assertEquals(feuille.mediaId, jouable.mediaId)
+ assertNotNull("le lecteur doit recevoir une source", jouable.localConfiguration)
+ }
+
+ @Test
+ fun `resoudre un album rend toutes ses pistes dans l'ordre`() {
+ // Demander un album, c'est demander ses pistes : l'hôte propose de
+ // jouer un nœud entier, et le lecteur doit recevoir la file complète.
+ val songs = listOf(song(1, "Une"), song(2, "Deux"))
+ val arbre = tree(songs)
+ val album = arbre.children("${BrowseTree.BROWSE_PREFIX}albums").single()
+
+ val file = arbre.resolve(album.mediaId)
+
+ assertEquals(listOf("Une", "Deux"), titlesOf(file))
+ assertTrue(file.all { it.localConfiguration != null })
+ }
+
+ @Test
+ fun `resoudre une section de navigation ne verse pas la bibliotheque entiere`() {
+ // `resolve` distingue un nœud qui contient des pistes d'un nœud qui
+ // contient d'autres nœuds. Confondre les deux ferait de « Albums » un
+ // raccourci vers toute la bibliothèque, alors que l'hôte n'a demandé
+ // qu'à ouvrir une section.
+ val arbre = tree(songs = listOf(song(1, "Une"), song(2, "Deux")))
+
+ assertEquals(emptyList(), arbre.resolve("${BrowseTree.BROWSE_PREFIX}albums"))
+ assertEquals(emptyList(), arbre.resolve("${BrowseTree.BROWSE_PREFIX}artists"))
+ assertEquals(emptyList(), arbre.resolve(BrowseTree.ROOT_ID))
+ }
+
+ @Test
+ fun `un identifiant inconnu ne rend rien plutot que d'echouer`() {
+ val arbre = tree(songs = listOf(song(1, "Une")))
+
+ assertEquals(emptyList(), arbre.children("${BrowseTree.BROWSE_PREFIX}album/999"))
+ assertEquals(emptyList(), arbre.children("n'importe quoi"))
+ assertNull(arbre.item("n'importe quoi"))
+ }
+
+ @Test
+ fun `un noeud de navigation ne peut pas etre pris pour une piste`() {
+ // Les deux familles d'identifiants se croisent dans les mêmes appels.
+ // Si un préfixe recouvrait l'autre, demander un album jouerait une
+ // piste, ou l'inverse.
+ val arbre = tree(songs = listOf(song(1, "Une")))
+ val piste = arbre.children("${BrowseTree.BROWSE_PREFIX}songs").single()
+ val album = arbre.children("${BrowseTree.BROWSE_PREFIX}albums").single()
+
+ assertFalse(piste.mediaId.startsWith(BrowseTree.BROWSE_PREFIX))
+ assertTrue(album.mediaId.startsWith(BrowseTree.BROWSE_PREFIX))
+ }
+}
diff --git a/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt b/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt
new file mode 100644
index 0000000..d3e42ba
--- /dev/null
+++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceBrowseTest.kt
@@ -0,0 +1,228 @@
+package app.waveflow.playback
+
+import android.app.Application
+import android.content.ComponentName
+import android.content.Intent
+import androidx.media3.session.MediaBrowser
+import androidx.media3.session.MediaLibraryService
+import androidx.media3.session.MediaSessionService
+import androidx.media3.session.SessionToken
+import androidx.test.core.app.ApplicationProvider
+import app.waveflow.WaveFlowApp
+import com.google.common.util.concurrent.ListenableFuture
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.android.controller.ServiceController
+import org.robolectric.shadows.ShadowLooper
+import java.util.concurrent.TimeUnit
+
+/**
+ * Ce qu'un navigateur déjà connecté apprend des changements de la bibliothèque.
+ *
+ * Le cas est celui de la voiture : Android Auto se lie au service au démarrage,
+ * bien avant que la bibliothèque de l'appareil ne soit lue, et n'a aucune raison
+ * de redemander quoi que ce soit ensuite. Si personne ne le prévient, il reste
+ * devant l'arbre qu'il a vu la première fois — vide.
+ *
+ * Rien n'est simulé : un vrai [PlaybackService], un vrai `MediaBrowser` abonné,
+ * et un changement provoqué par le chemin ordinaire de l'application — la
+ * création d'une playlist, qui fait apparaître une section dans la racine. Le
+ * MediaStore n'est pas mobilisé : il resterait vide sous Robolectric, alors que
+ * les playlists passent par une base dont l'application est propriétaire.
+ */
+@RunWith(RobolectricTestRunner::class)
+class PlaybackServiceBrowseTest {
+
+ private val app: Application = ApplicationProvider.getApplicationContext()
+
+ private var service: ServiceController? = null
+ private val browsers = mutableListOf()
+
+ @After
+ fun tearDown() {
+ browsers.forEach(MediaBrowser::release)
+ service?.destroy()
+ }
+
+ @Test
+ fun `un navigateur abonne est prevenu quand l'arbre change`() {
+ val recues = mutableListOf()
+ val navigateur = navigateurConnecte(recues)
+
+ navigateur.abonneALaRacine(recues)
+ // L'état de départ : la bibliothèque de l'appareil est vide et aucune
+ // playlist n'existe, la section n'a donc pas lieu d'être.
+ assertEquals(
+ listOf("Albums", "Artistes", "Toutes les pistes"),
+ titresDesEnfants(navigateur),
+ )
+
+ creerPlaylist("Sur la route")
+
+ attendre("la racine annoncée à quatre sections") { RACINE_AVEC_PLAYLISTS in recues }
+ assertEquals(
+ listOf("Albums", "Artistes", "Playlists", "Toutes les pistes"),
+ titresDesEnfants(navigateur),
+ )
+ }
+
+ @Test
+ fun `le depart d'un navigateur ne prive pas l'autre des notifications`() {
+ // Android Auto n'est pas seul à parcourir la bibliothèque : l'Assistant
+ // ou une autre application peuvent suivre le même nœud. Tenir soi-même
+ // le compte des abonnés reviendrait à retirer un nœud que quelqu'un
+ // regarde encore, et à le laisser sur un arbre figé.
+ val partant = mutableListOf()
+ val restant = mutableListOf()
+ val navigateurPartant = navigateurConnecte(partant)
+ val navigateurRestant = navigateurConnecte(restant)
+
+ navigateurPartant.abonneALaRacine(partant)
+ navigateurRestant.abonneALaRacine(restant)
+ navigateurPartant.unsubscribe(BrowseTree.ROOT_ID).attendu("le désabonnement du premier")
+
+ creerPlaylist("Sur la route")
+
+ attendre("la racine annoncée à quatre sections au navigateur resté") {
+ RACINE_AVEC_PLAYLISTS in restant
+ }
+ assertEquals(
+ listOf("Albums", "Artistes", "Playlists", "Toutes les pistes"),
+ titresDesEnfants(navigateurRestant),
+ )
+ }
+
+ /**
+ * S'abonne à la racine et attend l'état initial que la session envoie.
+ *
+ * Le futur de `subscribe` dit que l'abonnement est pris, pas que le premier
+ * `onChildrenChanged` est passé. Sans cette attente, celui-ci pourrait
+ * arriver après coup et se faire prendre pour la notification du changement
+ * qu'on cherche à prouver.
+ */
+ private fun MediaBrowser.abonneALaRacine(recues: MutableList) {
+ subscribe(BrowseTree.ROOT_ID, null).attendu("l'abonnement à la racine")
+ attendre("l'état initial de la racine") { recues.any { it.parentId == BrowseTree.ROOT_ID } }
+ recues.clear()
+ }
+
+ /** Le service tel qu'Android le crée, et un navigateur qui s'y lie. */
+ private fun navigateurConnecte(recues: MutableList): MediaBrowser {
+ val composant = ComponentName(app, PlaybackService::class.java)
+
+ // Un seul service pour tous les navigateurs, comme sur l'appareil.
+ if (service == null) {
+ val demarre = Robolectric.buildService(PlaybackService::class.java).create()
+ service = demarre
+
+ // Robolectric ne démarre pas de vrai service sur `bindService` : on
+ // lui donne le `Binder` que le service rend lui-même. Les deux
+ // actions parce que le navigateur choisit la sienne d'après ce que
+ // le manifeste déclare, et qu'il les déclare toutes les deux.
+ listOf(MediaLibraryService.SERVICE_INTERFACE, MediaSessionService.SERVICE_INTERFACE)
+ .forEach { action ->
+ val intent = Intent(action).setComponent(composant)
+ shadowOf(app).setComponentNameAndServiceForBindServiceForIntent(
+ intent,
+ composant,
+ demarre.get().onBind(intent),
+ )
+ }
+ }
+
+ val ecouteur = object : MediaBrowser.Listener {
+ override fun onChildrenChanged(
+ browser: MediaBrowser,
+ parentId: String,
+ itemCount: Int,
+ params: MediaLibraryService.LibraryParams?,
+ ) {
+ recues += Annonce(parentId, itemCount)
+ }
+ }
+
+ return MediaBrowser.Builder(app, SessionToken(app, composant))
+ .setListener(ecouteur)
+ .buildAsync()
+ .attendu("la liaison au service")
+ .also { browsers += it }
+ }
+
+ private fun titresDesEnfants(navigateur: MediaBrowser): List =
+ navigateur.getChildren(BrowseTree.ROOT_ID, 0, Int.MAX_VALUE, null)
+ .attendu("les enfants de la racine")
+ .value
+ .orEmpty()
+ .map { it.mediaMetadata.title.toString() }
+
+ /**
+ * Crée une playlist par le chemin de l'application.
+ *
+ * Le service observe le même dépôt : l'écriture fait donc émettre le flux
+ * qu'il collecte, exactement comme un ajout depuis l'écran des playlists.
+ */
+ private fun creerPlaylist(nom: String) {
+ runBlocking { (app as WaveFlowApp).container.playlistRepository.create(nom) }
+ }
+
+ /**
+ * Attend un futur en écoulant les messages entre deux essais.
+ *
+ * Un `get()` bloquant depuis le fil de test figerait la boucle principale,
+ * dont dépend justement la réponse de la session.
+ */
+ private fun ListenableFuture.attendu(quoi: String): T {
+ attendre(quoi) { isDone }
+ return get()
+ }
+
+ private fun attendre(quoi: String, condition: () -> Boolean) {
+ val echeance = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_S)
+ while (true) {
+ ecouler()
+ if (condition()) return
+ if (System.nanoTime() >= echeance) break
+ Thread.sleep(PAUSE_MS)
+ }
+ fail("Délai dépassé en attendant : $quoi")
+ }
+
+ /**
+ * Écoule les messages de toutes les boucles encore vivantes.
+ *
+ * Toutes, et pas seulement la principale : ExoPlayer tient sa machine à
+ * états sur un `HandlerThread` à lui.
+ */
+ private fun ecouler() {
+ ShadowLooper.getAllLoopers()
+ .filter { it.thread.isAlive }
+ // `getAllLoopers` ramasse aussi les boucles des tests précédents,
+ // dont les fils s'arrêtent — « Looper is quitting ».
+ .forEach { boucle -> runCatching { shadowOf(boucle).idle() } }
+ }
+
+ /**
+ * Ce qu'un navigateur apprend d'un nœud qui a changé.
+ *
+ * Le compte fait partie de ce qu'on éprouve, et pas seulement l'arrivée
+ * d'une annonce : le service en émet plusieurs pendant que la
+ * bibliothèque et les playlists se chargent, et n'importe laquelle serait
+ * sinon prise pour celle qu'on attend.
+ */
+ private data class Annonce(val parentId: String, val itemCount: Int)
+
+ private companion object {
+ /** La racine une fois qu'une playlist existe : la quatrième section. */
+ val RACINE_AVEC_PLAYLISTS = Annonce(BrowseTree.ROOT_ID, 4)
+
+ const val TIMEOUT_S = 15L
+ const val PAUSE_MS = 5L
+ }
+}