Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/lint.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,17 @@
name="opt-in"
value="androidx.media3.common.util.UnstableApi" />
</issue>

<!--
Déclarer l'application à Android Auto la rend éligible à « joue tel
album » : le lint réclame alors `MEDIA_PLAY_FROM_SEARCH`. Il a raison sur
le fond — la commande vocale est le mode d'interaction principal au
volant — mais la recherche est un chantier à part de l'arbre de
navigation, et déclarer un filtre d'intention qu'on ne sert pas ouvrirait
une porte sur une pièce vide : l'application s'ouvrirait sans jouer.

Rétrogradé plutôt que supprimé : la remontée reste visible à chaque build,
et disparaîtra d'elle-même quand la recherche arrivera.
-->
<issue id="MissingIntentFilterForMediaSearch" severity="warning" />
</lint>
13 changes: 12 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,24 @@
</intent-filter>
</activity>

<!-- Service de lecture Media3 : audio background + contrôles lockscreen/notif -->
<!-- Déclare l'application à Android Auto. Sans cette entrée, Auto ne la
voit pas, quelle que soit la session qu'elle publie. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />

<!-- Service de lecture Media3 : audio background + contrôles lockscreen/notif,
et arbre de navigation pour Android Auto (voir BrowseTree). -->
<service
android:name=".playback.PlaybackService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaLibraryService" />
<!-- L'action héritée reste déclarée : c'est celle que cherchent
Android Auto et les contrôleurs antérieurs à Media3. -->
<action android:name="android.media.browse.MediaBrowserService" />
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
Expand Down
151 changes: 151 additions & 0 deletions app/src/main/java/app/waveflow/playback/BrowseCallback.kt
Original file line number Diff line number Diff line change
@@ -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<String> = ConcurrentHashMap.newKeySet()

override fun onSubscribe(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
params: LibraryParams?,
): ListenableFuture<LibraryResult<Void>> {
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<LibraryResult<MediaItem>> =
Futures.immediateFuture(LibraryResult.ofItem(tree.root(), params))

override fun onGetChildren(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
page: Int,
pageSize: Int,
params: LibraryParams?,
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
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<LibraryResult<MediaItem>> {
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<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> {
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 <T> List<T>.page(page: Int, pageSize: Int): List<T> {
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)
}
Loading