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 2839e5a..d3718f3 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogApi.kt @@ -62,12 +62,18 @@ interface CatalogApi { * Le ticket ne porte pas le rendu : [rendering] s'ajoute à l'URL rendue, et * le serveur le lit au moment de servir. L'original n'y ajoute rien — le * serveur refuse un débit accompagnant `raw`. + * + * @param offsetMs l'instant du morceau où le flux doit commencer. Un + * transcodage en direct n'a pas de plages : c'est ainsi qu'on s'y déplace. + * Ignoré pour l'original, que le serveur refuse de décaler et qui se + * déplace par plages. */ suspend fun streamTicket( serverUrl: String, accessToken: String, trackId: String, rendering: StreamRendering, + offsetMs: Long = 0L, ): 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 05e670b..e3b917b 100644 --- a/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt +++ b/app/src/main/java/app/waveflow/data/remote/CatalogRepository.kt @@ -39,9 +39,11 @@ class CatalogRepository( * 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. + * + * @param offsetMs voir [CatalogApi.streamTicket]. */ - suspend fun streamUrl(trackId: String, rendering: StreamRendering): String = - authorized { url, token -> api.streamTicket(url, token, trackId, rendering) } + suspend fun streamUrl(trackId: String, rendering: StreamRendering, offsetMs: Long = 0L): String = + authorized { url, token -> api.streamTicket(url, token, trackId, rendering, offsetMs) } /** * 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/HttpCatalogApi.kt b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt index fab4ff1..0af4030 100644 --- a/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt +++ b/app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt @@ -98,6 +98,7 @@ class HttpCatalogApi( accessToken: String, trackId: String, rendering: StreamRendering, + offsetMs: Long, ): String { val ticket = http.post( serverUrl = serverUrl, @@ -106,17 +107,21 @@ class HttpCatalogApi( accessToken = accessToken, ).decode() - return http.absoluteUrl(serverUrl, ticket.url, query = rendering.toQuery()) + return http.absoluteUrl(serverUrl, ticket.url, query = rendering.toQuery(offsetMs)) } - /** L'original ne dit rien : le serveur le sert par défaut, et refuse qu'on lui donne un débit. */ - private fun StreamRendering.toQuery(): Map = + /** + * L'original ne dit rien : le serveur le sert par défaut, et refuse qu'on + * lui donne un débit ou un décalage. + */ + private fun StreamRendering.toQuery(offsetMs: Long): Map = if (isOriginal) { emptyMap() } else { buildMap { put("format", format) bitrate?.let { put("bitrate", it.toString()) } + if (offsetMs > 0L) put("offset_ms", offsetMs.toString()) } } diff --git a/app/src/main/java/app/waveflow/playback/LogicalTimeline.kt b/app/src/main/java/app/waveflow/playback/LogicalTimeline.kt new file mode 100644 index 0000000..b19b58e --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/LogicalTimeline.kt @@ -0,0 +1,70 @@ +package app.waveflow.playback + +import androidx.media3.common.C +import androidx.media3.common.Timeline +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.source.ForwardingTimeline + +/** + * La timeline de l'ExoPlayer, telle qu'Android doit la voir. + * + * Enveloppée et non reconstruite : la navigation — suivant, précédent, ordre + * aléatoire — reste celle de l'ExoPlayer. Reposer la liste de lecture sous forme + * de liste fabriquerait une `PlaylistTimeline`, qui ignore l'ordre aléatoire. + * + * Trois corrections, sur les seules pistes transcodées : + * + * - l'identifiant d'une piste relancée est celui qu'elle avait avant (R5) ; + * - la durée est celle du catalogue quand le flux n'en dit rien, ou quand un + * segment joue et n'en connaît que le reste (R3) ; + * - la piste est déplaçable (R4), et son élément est le marqueur sans décalage. + * + * @param fenetres identifiant logique, par identifiant de fenêtre de l'ExoPlayer. + * @param periodes identifiant logique, par identifiant de période de l'ExoPlayer. + * @param decalages décalage du flux, par identifiant de fenêtre de l'ExoPlayer. + */ +internal class LogicalTimeline( + timeline: Timeline, + private val fenetres: Map, + private val periodes: Map, + private val decalages: Map, +) : ForwardingTimeline(timeline) { + + private val periodesBrutes: Map = periodes.entries.associate { (brute, logique) -> logique to brute } + + override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + super.getWindow(windowIndex, window, defaultPositionProjectionUs) + val brute = window.uid + fenetres[brute]?.let { window.uid = it } + + val item = window.mediaItem + if (item.isRemoteTranscode()) { + window.mediaItem = item.withStreamOffset(0L) + window.isSeekable = true + val catalogueMs = item.mediaMetadata.durationMs + if (catalogueMs != null && (decalages.containsKey(brute) || window.durationUs == C.TIME_UNSET)) { + window.durationUs = Util.msToUs(catalogueMs) + } + } + return window + } + + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + super.getPeriod(periodIndex, period, setIds) + if (setIds) period.uid?.let { brute -> periodes[brute]?.let { period.uid = it } } + + // Un flux progressif n'a qu'une période, qui couvre sa fenêtre : sa durée + // suit celle de la fenêtre corrigée, sans quoi la fin du morceau se lirait + // à la fin du segment. + val fenetre = getWindow(period.windowIndex, Window()) + if (fenetre.mediaItem.isRemoteTranscode() && period.positionInWindowUs == 0L) { + period.durationUs = fenetre.durationUs + } + return period + } + + override fun getIndexOfPeriod(uid: Any): Int = super.getIndexOfPeriod(periodesBrutes[uid] ?: uid) + + override fun getUidOfPeriod(periodIndex: Int): Any = + super.getUidOfPeriod(periodIndex).let { periodes[it] ?: it } +} diff --git a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt index 79fadba..da22182 100644 --- a/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt +++ b/app/src/main/java/app/waveflow/playback/MediaItemMapper.kt @@ -36,6 +36,9 @@ fun Song.toMediaItem(): MediaItem = * 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. + * + * La durée du catalogue suit la piste : un transcodage en direct n'en annonce + * aucune, et c'est elle que la timeline logique expose à sa place. */ fun RemoteSong.toMediaItem(): MediaItem = MediaItem.Builder() @@ -48,6 +51,7 @@ fun RemoteSong.toMediaItem(): MediaItem = .setArtist(artist) .setAlbumTitle(album) .setArtworkUri(artworkUri) + .setDurationMs(durationMs.takeIf { it > 0L }) .build(), ) .build() @@ -135,32 +139,65 @@ internal const val DEFAULT_FORMAT = StreamRendering.FORMAT_ORIGINAL * que la qualité ne se choisisse : le cache déjà constitué reste valable. * * Une piste locale est rendue telle quelle : elle ne passe pas par le serveur. + * Un décalage déjà posé est effacé : une piste qui entre dans la file part du + * début. */ internal fun MediaItem.withRendering(rendering: StreamRendering): MediaItem { val uri = localConfiguration?.uri ?: return this val trackId = trackIdOfRemoteUri(uri) ?: return this - val marqueur = uri.buildUpon().clearQuery().apply { + return buildUpon() + .setUri(uri.marqueur(rendering, offsetMs = 0L)) + .setCustomCacheKey(cacheKeyOf(trackId, rendering.format, rendering.bitrate)) + .build() +} + +/** + * Pose sur une piste distante transcodée l'instant où son flux doit commencer. + * + * Le décalage voyage dans le marqueur, comme le rendu : [RemoteStreamResolver] + * le relit, et la chaîne de lecture aiguille un segment hors du cache d'après ce + * même marqueur. Aucun état partagé ne s'intercale entre les deux. Voir + * `docs/deplacement-dans-un-transcodage.md`, R10 et R11. + * + * La clé de cache ne change pas, et `0` rend exactement le marqueur que + * [withRendering] avait posé : la piste revenue au début retrouve son cache. + * + * Sans effet sur une piste locale, ni sur l'original : le serveur refuse un + * décalage sur `raw` (R8), et l'original se déplace par plages. + */ +internal fun MediaItem.withStreamOffset(offsetMs: Long): MediaItem { + val uri = localConfiguration?.uri ?: return this + trackIdOfRemoteUri(uri) ?: return this + + return buildUpon().setUri(uri.marqueur(renderingOfRemoteUri(uri), offsetMs)).build() +} + +/** Le marqueur de cette piste pour ce rendu, à partir de cet instant. */ +private fun android.net.Uri.marqueur(rendering: StreamRendering, offsetMs: Long): android.net.Uri = + buildUpon().clearQuery().apply { if (!rendering.isOriginal) { appendQueryParameter(PARAM_FORMAT, rendering.format) rendering.bitrate?.let { appendQueryParameter(PARAM_BITRATE, it.toString()) } + if (offsetMs > 0L) appendQueryParameter(PARAM_OFFSET, offsetMs.toString()) } }.build() - return buildUpon() - .setUri(marqueur) - .setCustomCacheKey(cacheKeyOf(trackId, rendering.format, rendering.bitrate)) - .build() -} - /** Le rendu que porte le marqueur d'une piste distante ; l'original s'il n'en dit rien. */ internal fun renderingOfRemoteUri(uri: android.net.Uri): StreamRendering { val format = uri.getQueryParameter(PARAM_FORMAT) ?: return StreamRendering.ORIGINAL return StreamRendering(format, uri.getQueryParameter(PARAM_BITRATE)?.toIntOrNull()) } +/** L'instant du morceau où commence le flux de ce marqueur ; `0` hors segment. */ +internal fun streamOffsetOfRemoteUri(uri: android.net.Uri): Long { + if (trackIdOfRemoteUri(uri) == null) return 0L + return uri.getQueryParameter(PARAM_OFFSET)?.toLongOrNull()?.coerceAtLeast(0L) ?: 0L +} + private const val PARAM_FORMAT = "format" private const val PARAM_BITRATE = "bitrate" +private const val PARAM_OFFSET = "offset_ms" /** Identifiant de piste serveur, ou `null` si la piste est locale. */ internal fun trackIdOfRemoteUri(uri: android.net.Uri): String? = diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index d9918d9..37946f4 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -92,7 +92,7 @@ class PlaybackService : MediaLibraryService() { RemoteStreamResolver(container.catalogRepository), ) - val player = ExoPlayer.Builder(this) + val exoPlayer = ExoPlayer.Builder(this) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) // Route audio "musique" + gestion du focus audio (pause si un appel // arrive, etc.). @@ -107,6 +107,12 @@ class PlaybackService : MediaLibraryService() { .setHandleAudioBecomingNoisy(true) .build() + // Tout ce qui suit parle à l'enveloppe, jamais à l'ExoPlayer : dans un + // morceau transcodé relancé à 2:13, lui compte depuis zéro, et la + // notification, la voiture, l'historique ou la boucle A-B s'y + // tromperaient. Voir `docs/deplacement-dans-un-transcodage.md`, R1. + val player: Player = TranscodeSeekingPlayer(exoPlayer) + // Sans ce chargeur, Media3 irait chercher les pochettes avec son propre // client HTTP, qui ne porte pas le jeton de session : `/api/v2/artwork/` // lui répondait 401 et la notification restait sans vignette. diff --git a/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt b/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt index 2ffaf33..8f573a7 100644 --- a/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt +++ b/app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt @@ -52,7 +52,10 @@ class RemoteMediaCache(context: Context) : PlaybackCache { * - `DefaultDataSource` aiguille en amont selon le schéma : `content://` et * `file://` partent vers les sources locales sans jamais toucher au cache ; * - au-dessus du cache, [IncompleteTranscodeEviction] retire le début d'un - * transcodage quitté en route, que le serveur ne laisserait pas compléter. + * transcodage quitté en route, que le serveur ne laisserait pas compléter ; + * - au-dessus encore, [SegmentCacheBypass] envoie un segment droit au + * résolveur : ses octets ne sont pas ceux du morceau, et le cache les + * confondrait. */ fun dataSourceFactory(resolver: ResolvingDataSource.Resolver): DataSource.Factory { val resolving = ResolvingDataSource.Factory(DefaultHttpDataSource.Factory(), resolver) @@ -72,7 +75,14 @@ class RemoteMediaCache(context: Context) : PlaybackCache { IncompleteTranscodeEviction(cached.createDataSource(), cache, cles) } - return DefaultDataSource.Factory(appContext, sansDebutOrphelin) + val segmentsHorsCache = DataSource.Factory { + SegmentCacheBypass( + cached = sansDebutOrphelin.createDataSource(), + direct = resolving.createDataSource(), + ) + } + + return DefaultDataSource.Factory(appContext, segmentsHorsCache) } override val maxBytes: Long = MAX_BYTES diff --git a/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt index 3721fb9..0057108 100644 --- a/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt +++ b/app/src/main/java/app/waveflow/playback/RemoteStreamResolver.kt @@ -24,6 +24,10 @@ import java.io.IOException * clé de cache a été calculée depuis ce même marqueur, avant ce résolveur. Relire * le réglage ici laisserait un changement s'intercaler entre les deux, et une * version se rangerait sous le nom d'une autre. Voir [withRendering]. + * + * Le décalage d'un segment se lit au même endroit, pour la même raison : c'est + * sur ce marqueur que la chaîne a décidé de ne pas passer par le cache. Voir + * [withStreamOffset]. */ class RemoteStreamResolver( private val catalogRepository: CatalogRepository, @@ -32,9 +36,10 @@ class RemoteStreamResolver( override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { val trackId = trackIdOfRemoteUri(dataSpec.uri) ?: return dataSpec val rendering = renderingOfRemoteUri(dataSpec.uri) + val offsetMs = streamOffsetOfRemoteUri(dataSpec.uri) val url = try { - runBlocking { catalogRepository.streamUrl(trackId, rendering) } + runBlocking { catalogRepository.streamUrl(trackId, rendering, offsetMs) } } 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 diff --git a/app/src/main/java/app/waveflow/playback/SegmentCacheBypass.kt b/app/src/main/java/app/waveflow/playback/SegmentCacheBypass.kt new file mode 100644 index 0000000..1fd0185 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/SegmentCacheBypass.kt @@ -0,0 +1,54 @@ +package app.waveflow.playback + +import android.net.Uri +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener + +/** + * Fait passer un segment à côté du cache. + * + * Un segment — un flux demandé à partir d'un instant, `offset_ms` — n'est pas le + * morceau : ses octets ne commencent pas là où commencent ceux du morceau. Or le + * cache est posé **avant** le résolveur. Un segment qui s'y présenterait se + * verrait servir le morceau déjà en cache, depuis 0:00, pendant que l'écran + * afficherait l'instant demandé ; ou il y rangerait son reste sous un nom qui + * n'est pas le sien. Il va donc droit au résolveur, ni lu ni écrit. + * + * L'aiguillage se décide à l'ouverture, sur le marqueur même que le résolveur + * relira : aucun état partagé ne s'intercale entre les deux. Voir + * `docs/deplacement-dans-un-transcodage.md`, R11. + */ +internal class SegmentCacheBypass( + private val cached: DataSource, + private val direct: DataSource, +) : DataSource { + + private var ouverte: DataSource? = null + + override fun addTransferListener(transferListener: TransferListener) { + cached.addTransferListener(transferListener) + direct.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val source = if (streamOffsetOfRemoteUri(dataSpec.uri) > 0L) direct else cached + ouverte = source + return source.open(dataSpec) + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + checkNotNull(ouverte) { "lecture d'une source fermée" }.read(buffer, offset, length) + + override fun getUri(): Uri? = ouverte?.uri + + override fun getResponseHeaders(): Map> = ouverte?.responseHeaders.orEmpty() + + override fun close() { + try { + ouverte?.close() + } finally { + ouverte = null + } + } +} diff --git a/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt new file mode 100644 index 0000000..522c1f1 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt @@ -0,0 +1,277 @@ +package app.waveflow.playback + +import androidx.media3.common.C +import androidx.media3.common.ForwardingSimpleBasePlayer +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.Timeline +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +/** + * Présente à Android la timeline logique d'un morceau transcodé. + * + * Un transcodage en direct n'a pas de plages : s'y déplacer, c'est relancer le + * flux à partir d'un instant (`offset_ms`). ExoPlayer compte alors depuis zéro + * dans un flux qui commence à 2:13. Cette enveloppe rend à tout le reste — + * session, notification, Android Auto, et chaque écouteur du service — le + * morceau tel qu'il est : commencé à 0:00, de sa durée entière. + * + * Les règles sont dans `docs/deplacement-dans-un-transcodage.md` ; les renvois + * R1 à R16 ci-dessous y pointent. + */ +internal class TranscodeSeekingPlayer(player: Player) : ForwardingSimpleBasePlayer(player) { + + /** + * L'identifiant logique d'une fenêtre relancée, par identifiant de fenêtre du + * lecteur enveloppé : celui qu'elle avait avant sa première relance (R5). + */ + private val fenetresLogiques = HashMap() + + /** La même chose pour les périodes, d'où `SimpleBasePlayer` tire ses discontinuités. */ + private val periodesLogiques = HashMap() + + /** Le décalage du flux de chaque fenêtre relancée ; absente, elle part du début. */ + private val decalages = HashMap() + + /** La cible d'une relance, à annoncer comme un saut à la place de la discontinuité qu'elle provoque. */ + private var sautAnnonce: Long? = null + + /** Vrai quand un flux vient de finir et de reprendre sur la même piste : R12. */ + private var segmentRepete = false + + init { + // Deux choses ne se déduisent pas d'un état mais d'un événement : la fin + // d'un flux qui se répète, et une piste relancée qu'on vient de quitter. + player.addListener( + object : Player.Listener { + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + if (reason == DISCONTINUITY_REASON_AUTO_TRANSITION && + oldPosition.mediaItemIndex == newPosition.mediaItemIndex + ) { + segmentRepete = true + } + } + + override fun onEvents(player: Player, events: Player.Events) = veillerAuxSegments() + }, + ) + } + + override fun getState(): State { + val etat = super.getState() + val brute = etat.timeline + oublierLesAbsentes(brute) + val logique = LogicalTimeline( + timeline = brute, + fenetres = HashMap(fenetresLogiques), + periodes = HashMap(periodesLogiques), + decalages = HashMap(decalages), + ) + val builder = etat.buildUpon().setPlaylist(logique, etat.currentTracks, etat.currentMetadata) + if (!brute.isEmpty) corrigerLaPisteCourante(etat, logique, builder) + return builder.build() + } + + private fun corrigerLaPisteCourante(etat: State, logique: Timeline, builder: State.Builder) { + val index = etat.currentMediaItemIndex.takeIf { it != C.INDEX_UNSET } ?: 0 + val brute = etat.timeline.getWindow(index, Timeline.Window()) + if (!brute.mediaItem.isRemoteTranscode()) return + + val decalage = decalages[brute.uid] ?: 0L + val dureeMs = logique.getWindow(index, Timeline.Window()).durationMs + + builder + .setContentPositionMs(etat.contentPositionMsSupplier.logique(decalage, dureeMs)) + .setContentBufferedPositionMs(etat.contentBufferedPositionMsSupplier.logique(decalage, dureeMs)) + // R4 : ExoPlayer retire ces commandes d'une piste non déplaçable. + .setAvailableCommands( + etat.availableCommands.buildUpon() + .addAll(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, COMMAND_SEEK_BACK, COMMAND_SEEK_FORWARD) + .build(), + ) + + val saut = sautAnnonce + if (saut != null) { + sautAnnonce = null + builder.setPositionDiscontinuity(DISCONTINUITY_REASON_SEEK, saut) + } else if (etat.hasPositionDiscontinuity) { + builder.setPositionDiscontinuity( + etat.positionDiscontinuityReason, + logicalPositionMs(etat.discontinuityPositionMs, decalage, dureeMs), + ) + } + } + + /** + * `BasePlayer` a déjà calculé la cible sur l'état de l'enveloppe, donc sur la + * position logique ; `ForwardingSimpleBasePlayer` la jetterait pour laisser + * l'ExoPlayer la recalculer sur sa position brute (R13). + */ + override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + val courant = player.currentMediaItemIndex + when (seekCommand) { + COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, COMMAND_SEEK_BACK, COMMAND_SEEK_FORWARD -> + allerA(courant, positionMs) + + COMMAND_SEEK_TO_MEDIA_ITEM -> allerA(mediaItemIndex, positionMs) + + // `seekToDefaultPosition()` sans rang, qu'un contrôleur envoie pour + // revenir au début : le début du morceau est un décalage `0`, là où + // l'ExoPlayer ramènerait au début du flux, c'est-à-dire au milieu du + // morceau. La variante avec rang, celle de la file, passe par + // `COMMAND_SEEK_TO_MEDIA_ITEM`, juste au-dessus. + COMMAND_SEEK_TO_DEFAULT_POSITION -> allerA(mediaItemIndex, C.TIME_UNSET) + + // Recommencer ou reculer d'une piste : tranché sur la position + // logique. Reculer d'une piste reste à l'ExoPlayer, qui connaît + // l'ordre aléatoire. + COMMAND_SEEK_TO_PREVIOUS -> + if (mediaItemIndex == courant) allerA(courant, 0L) else player.seekToPreviousMediaItem() + + else -> return super.handleSeek(mediaItemIndex, positionMs, seekCommand) + } + return Futures.immediateVoidFuture() + } + + /** R7 : nativement quand l'ExoPlayer le peut, par relance sinon. */ + private fun allerA(index: Int, positionMs: Long) { + val timeline = player.currentTimeline + if (index == C.INDEX_UNSET || index >= timeline.windowCount) return + + val fenetre = timeline.getWindow(index, Timeline.Window()) + val cible = if (positionMs == C.TIME_UNSET) 0L else positionMs.coerceAtLeast(0L) + val decalage = decalages[fenetre.uid] ?: 0L + val natif = !fenetre.mediaItem.isRemoteTranscode() || + (decalage == 0L && (fenetre.isSeekable || cible == 0L)) + + if (natif) { + player.seekTo(index, positionMs) + } else { + relancer(index, fenetre, bornerAuMorceau(cible, fenetre.mediaItem)) + } + } + + private fun relancer(index: Int, fenetre: Timeline.Window, decalageMs: Long) { + remplacer(index, fenetre, decalageMs) + + if (index == player.currentMediaItemIndex) { + // R6 : annoncé tout de suite, le curseur ne revient pas en arrière. + sautAnnonce = decalageMs + } else { + player.seekTo(index, 0L) + } + } + + /** + * Échange le flux d'une piste contre celui qui commence à [decalageMs], en + * lui gardant son identité. + * + * L'ExoPlayer ne sait pas mettre à jour une source dont l'URI change : il + * retire et ajoute, d'où de nouveaux identifiants. Les rattacher juste après + * suffit — Media3 livre `onEvents` par un message posté, et l'enveloppe ne + * relit donc son état qu'ensuite. + */ + private fun remplacer(index: Int, fenetre: Timeline.Window, decalageMs: Long) { + val avant = player.currentTimeline + val fenetreLogique = fenetresLogiques[fenetre.uid] ?: fenetre.uid + val periodeBrute = avant.getUidOfPeriod(fenetre.firstPeriodIndex) + val periodeLogique = periodesLogiques[periodeBrute] ?: periodeBrute + + player.replaceMediaItem(index, fenetre.mediaItem.withStreamOffset(decalageMs)) + + val apres = player.currentTimeline + val nouvelle = apres.getWindow(index, Timeline.Window()) + fenetresLogiques[nouvelle.uid] = fenetreLogique + periodesLogiques[apres.getUidOfPeriod(nouvelle.firstPeriodIndex)] = periodeLogique + if (decalageMs > 0L) decalages[nouvelle.uid] = decalageMs + } + + /** + * R12 : un flux qui se répète rejouerait les dernières secondes d'un segment + * sans fin ; on repart du début du morceau. R14 : une piste relancée qu'on a + * quittée retrouve son marqueur nu, sans quoi y revenir la ferait repartir au + * milieu. + */ + private fun veillerAuxSegments() { + var change = false + + if (segmentRepete) { + segmentRepete = false + val courant = player.currentMediaItemIndex + val courante = player.currentTimeline + // Bornée comme dans [allerA] : la file peut s'être vidée entre la + // discontinuité et cette salve d'événements, et il ne reste alors + // aucune fenêtre à lire. Garde **non éprouvée** — la fenêtre est + // trop étroite pour qu'un test la vise —, mais une exception ici + // emporterait le service de lecture. + if (courant != C.INDEX_UNSET && courant < courante.windowCount) { + val fenetre = courante.getWindow(courant, Timeline.Window()) + if ((decalages[fenetre.uid] ?: 0L) > 0L) { + relancer(courant, fenetre, 0L) + change = true + } + } + } + + val timeline = player.currentTimeline + for (index in 0 until timeline.windowCount) { + if (index == player.currentMediaItemIndex) continue + val fenetre = timeline.getWindow(index, Timeline.Window()) + if ((decalages[fenetre.uid] ?: 0L) > 0L) { + remplacer(index, fenetre, 0L) + change = true + } + } + + if (change) invalidateState() + } + + /** Ce que le lecteur enveloppé ne connaît plus n'a plus à être rattaché à rien. */ + private fun oublierLesAbsentes(timeline: Timeline) { + val fenetres = HashSet() + val periodes = HashSet() + val fenetre = Timeline.Window() + for (i in 0 until timeline.windowCount) fenetres += timeline.getWindow(i, fenetre).uid + for (i in 0 until timeline.periodCount) periodes += timeline.getUidOfPeriod(i) + fenetresLogiques.keys.retainAll(fenetres) + decalages.keys.retainAll(fenetres) + periodesLogiques.keys.retainAll(periodes) + } +} + +/** + * R8 : le serveur refuse un décalage au-delà de la durée. Un saut à la toute fin + * rend un flux vide, qui finit aussitôt — la fin du morceau. + */ +private fun bornerAuMorceau(cibleMs: Long, item: MediaItem): Long { + val dureeMs = item.mediaMetadata.durationMs ?: return cibleMs + return cibleMs.coerceAtMost((dureeMs - 1L).coerceAtLeast(0L)) +} + +private fun SimpleBasePlayer.PositionSupplier.logique(decalageMs: Long, dureeMs: Long) = + SimpleBasePlayer.PositionSupplier { logicalPositionMs(get(), decalageMs, dureeMs) } + +/** + * La position logique d'une position lue dans le flux (R2). + * + * Une position inconnue le reste : lui ajouter le décalage publierait un instant + * plausible et faux. Et la position reste dans le morceau, un flux transcodé + * pouvant durer un peu plus que ce que le catalogue annonce. + */ +internal fun logicalPositionMs(streamPositionMs: Long, offsetMs: Long, durationMs: Long): Long { + if (streamPositionMs == C.TIME_UNSET) return C.TIME_UNSET + val logique = (streamPositionMs + offsetMs).coerceAtLeast(0L) + return if (durationMs == C.TIME_UNSET) logique else logique.coerceAtMost(durationMs) +} + +/** Une piste du serveur servie transcodée : la seule dont le flux peut devoir être relancé. */ +internal fun MediaItem.isRemoteTranscode(): Boolean { + val uri = localConfiguration?.uri ?: return false + return trackIdOfRemoteUri(uri) != null && !renderingOfRemoteUri(uri).isOriginal +} 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 316ef58..a5b32df 100644 --- a/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt +++ b/app/src/test/java/app/waveflow/data/remote/HttpCatalogApiTest.kt @@ -227,6 +227,39 @@ class HttpCatalogApiTest { assertEquals("${url()}/api/v2/stream/VkdLrczM?format=opus&bitrate=96", streamUrl) } + @Test + fun `un decalage s'ajoute a l'URL d'un transcodage`() = runTest { + // Un transcodage en direct n'a pas de plages : c'est par cet instant + // qu'on s'y déplace. + server.enqueue(MockResponse().setBody(TICKET_BODY)) + + val streamUrl = api.streamTicket( + url(), + "wfa_1", + "c07f8d98", + StreamRendering("opus", 96), + offsetMs = 133_000L, + ) + + assertEquals("${url()}/api/v2/stream/VkdLrczM?format=opus&bitrate=96&offset_ms=133000", streamUrl) + } + + @Test + fun `l'original ne recoit jamais de decalage`() = runTest { + // Le serveur le refuserait en 422 : l'original se déplace par plages. + server.enqueue(MockResponse().setBody(TICKET_BODY)) + + val streamUrl = api.streamTicket( + url(), + "wfa_1", + "c07f8d98", + StreamRendering.ORIGINAL, + offsetMs = 133_000L, + ) + + assertEquals("${url()}/api/v2/stream/VkdLrczM", streamUrl) + } + @Test fun `un jeton refuse remonte comme tel`() = runTest { server.enqueue( diff --git a/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt index 993e16c..b2afaee 100644 --- a/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt +++ b/app/src/test/java/app/waveflow/playback/MediaItemMapperTest.kt @@ -178,6 +178,77 @@ class MediaItemMapperTest { assertNull(track.artworkUri) } + @Test + fun `une piste distante emporte la duree du catalogue`() { + // Un transcodage en direct n'annonce aucune durée : sans celle-ci, le + // curseur resterait désactivé. + val item = remoteSong(id = "a", durationMs = 245_000L).toMediaItem() + + assertEquals(245_000L, item.mediaMetadata.durationMs) + } + + @Test + fun `une duree inconnue du catalogue n'est pas inventee`() { + val item = remoteSong(id = "a", durationMs = 0L).toMediaItem() + + assertNull(item.mediaMetadata.durationMs) + } + + @Test + fun `le decalage pose se relit sur le marqueur, sans toucher au rendu ni a la cle`() { + val avant = remoteSong(id = "a").toMediaItem().withRendering(StreamRendering("opus", 96)) + + val segment = avant.withStreamOffset(133_000L) + val uri = segment.localConfiguration!!.uri + + assertEquals(133_000L, streamOffsetOfRemoteUri(uri)) + assertEquals(StreamRendering("opus", 96), renderingOfRemoteUri(uri)) + assertEquals("a", trackIdOfRemoteUri(uri)) + assertEquals(avant.localConfiguration?.customCacheKey, segment.localConfiguration?.customCacheKey) + } + + @Test + fun `revenir au debut rend le marqueur d'avant le segment`() { + // C'est ce qui rend à la piste son cache : un marqueur qui garderait + // une trace du segment ne serait plus celui sous lequel elle est rangée. + val avant = remoteSong(id = "a").toMediaItem().withRendering(StreamRendering("opus", 96)) + + val revenu = avant.withStreamOffset(133_000L).withStreamOffset(0L) + + assertEquals(avant, revenu) + } + + @Test + fun `l'original et une piste locale ne recoivent aucun decalage`() { + // Le serveur refuse un décalage sur `raw`, et l'original se déplace par + // plages ; une piste locale ne passe pas par le serveur. + val original = remoteSong(id = "a").toMediaItem() + val locale = song(id = 42L).toMediaItem() + + assertEquals(original, original.withStreamOffset(133_000L)) + assertEquals(locale, locale.withStreamOffset(133_000L)) + } + + @Test + fun `seul un marqueur porte un decalage`() { + // L'aiguillage hors du cache ne regarde que les marqueurs : une URL qui + // n'en est pas un ne doit pas passer pour un segment. + val url = "https://musique.test/api/v2/stream/ticket?format=opus&offset_ms=133000".toUri() + + assertEquals(0L, streamOffsetOfRemoteUri(url)) + } + + @Test + fun `reposer un rendu efface le decalage`() { + // Une piste qui entre dans la file part du début. + val item = remoteSong(id = "a").toMediaItem() + .withRendering(StreamRendering("opus", 96)) + .withStreamOffset(133_000L) + .withRendering(StreamRendering("opus", 96)) + + assertEquals(0L, streamOffsetOfRemoteUri(item.localConfiguration!!.uri)) + } + private companion object { val ARTWORK = "https://serveur.test/api/v2/artwork/1f2e3d".toUri() } diff --git a/app/src/test/java/app/waveflow/playback/PlaybackServiceSeekTest.kt b/app/src/test/java/app/waveflow/playback/PlaybackServiceSeekTest.kt new file mode 100644 index 0000000..9711529 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceSeekTest.kt @@ -0,0 +1,159 @@ +package app.waveflow.playback + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.os.Bundle +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionToken +import androidx.test.core.app.ApplicationProvider +import app.waveflow.WaveFlowApp +import app.waveflow.data.PreferencesStore +import app.waveflow.model.StreamQuality +import app.waveflow.testing.remoteSong +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertTrue +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 + +/** + * Le déplacement dans un transcodage, vu par un vrai `MediaController`. + * + * `TranscodeSeekingPlayerTest` éprouve l'enveloppe seule. Il reste à montrer que + * c'est bien elle que la **session** tient : la notification et Android Auto ne + * lisent que la session, et une enveloppe posée à côté ne les corrigerait pas + * (R1 de `docs/deplacement-dans-un-transcodage.md`). + * + * Le vrai magasin de l'application, remis à l'origine après chaque test : le + * délégué `preferencesDataStore` mémorise son instance pour toute la machine + * virtuelle, et ce qu'on laisse derrière soi, un autre test le relira. + */ +@RunWith(RobolectricTestRunner::class) +class PlaybackServiceSeekTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + private val preferences: PreferencesStore get() = (app as WaveFlowApp).container.preferencesStore + + private var service: ServiceController? = null + private var controleur: MediaController? = null + + @After + fun tearDown() { + controleur?.release() + service?.destroy() + runBlocking { preferences.setStreamQuality(StreamQuality.Original) } + } + + @Test + fun `la session annonce la duree du catalogue et le saut dans un transcodage`() { + // Sans l'enveloppe, l'ExoPlayer ne connaît ni la durée d'un transcodage + // en direct ni le moyen de s'y déplacer : le contrôleur n'aurait ni l'une + // ni l'autre. + runBlocking { preferences.setStreamQuality(StreamQuality.Economie) } + demarrerLeService() + val controleur = controleurConnecte() + + controleur.setMediaItems(listOf(remoteSong(id = "c07f8d98", durationMs = DUREE_MS).toMediaItem())) + attendre("la durée du catalogue côté contrôleur") { controleur.duration == DUREE_MS } + + assertTrue(controleur.isCommandAvailable(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)) + } + + /** Le service tel qu'Android le crée. */ + private fun demarrerLeService() { + 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, pour les deux actions + // que le manifeste déclare. + val composant = ComponentName(app, PlaybackService::class.java) + listOf(MediaLibraryService.SERVICE_INTERFACE, MediaSessionService.SERVICE_INTERFACE) + .forEach { action -> + val intent = Intent(action).setComponent(composant) + shadowOf(app).setComponentNameAndServiceForBindServiceForIntent( + intent, + composant, + demarre.get().onBind(intent), + ) + } + checkNotNull(demarre.get().onGetSession(appelant())) + } + + /** Un contrôleur lié au service, par le même chemin que l'application. */ + private fun controleurConnecte(): MediaController { + val composant = ComponentName(app, PlaybackService::class.java) + return MediaController.Builder(app, SessionToken(app, composant)) + .buildAsync() + .attendu("la liaison au service") + .also { controleur = it } + } + + private fun appelant(): MediaSession.ControllerInfo = + MediaSession.ControllerInfo.createTestOnlyControllerInfo( + /* packageName = */ "app.waveflow", + /* pid = */ 0, + /* uid = */ 0, + /* libraryVersion = */ 0, + /* interfaceVersion = */ 0, + /* trusted = */ true, + /* connectionHints = */ Bundle.EMPTY, + /* isPackageNameVerified = */ true, + ) + + /** + * 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() + } + + /** + * Bornée : un état qui n'arriverait plus doit faire échouer le test, pas le + * faire attendre indéfiniment. + */ + 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 : ExoPlayer + * tient sa machine à états sur un `HandlerThread` à lui, et le DataStore + * lit sur son propre dispatcher. + */ + private fun ecouler() { + ShadowLooper.getAllLoopers() + .filter { it.thread.isAlive } + .forEach { boucle -> runCatching { shadowOf(boucle).idle() } } + } + + private companion object { + const val DUREE_MS = 245_000L + const val TIMEOUT_S = 15L + const val PAUSE_MS = 5L + } +} diff --git a/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt b/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt index 1847045..3b1717f 100644 --- a/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt +++ b/app/src/test/java/app/waveflow/playback/RemoteMediaCacheTest.kt @@ -66,7 +66,8 @@ class RemoteMediaCacheTest { /** * Un résolveur qui rend une URL différente à chaque appel, comme le vrai. * - * Le rendu du marqueur suit jusqu'au serveur, comme dans [RemoteStreamResolver]. + * Le rendu et le décalage du marqueur suivent jusqu'au serveur, comme dans + * [RemoteStreamResolver]. */ private val resolver = ResolvingDataSource.Resolver { dataSpec -> val trackId = trackIdOfRemoteUri(dataSpec.uri) ?: return@Resolver dataSpec @@ -112,11 +113,13 @@ class RemoteMediaCacheTest { trackId: String, format: String = DEFAULT_FORMAT, bitrate: Int? = null, + offsetMs: Long = 0L, ): DataSpec { val piste = MediaItem.Builder() .setUri("waveflow://track/$trackId".toUri()) .build() .withRendering(StreamRendering(format, bitrate)) + .withStreamOffset(offsetMs) val configuration = checkNotNull(piste.localConfiguration) return DataSpec.Builder() .setUri(configuration.uri) @@ -127,7 +130,8 @@ class RemoteMediaCacheTest { /** * Répond comme `waveflow-server` (`src/media.rs`) : l'original se sert par * plages ; un transcodage en direct arrive par morceaux, sans longueur, et - * refuse toute plage qui ne part pas du premier octet. + * refuse toute plage qui ne part pas du premier octet ; un segment est un + * autre flux, qui commence à l'instant demandé. */ private fun servirCommeLeServeur(contenu: ByteArray) { server.dispatcher = object : Dispatcher() { @@ -138,12 +142,17 @@ class RemoteMediaCacheTest { ?.toInt() ?: 0 val transcode = request.requestUrl?.queryParameter("format") != null + val decalage = request.requestUrl?.queryParameter("offset_ms")?.toLong() ?: 0L return when { transcode && debut > 0 -> MockResponse() .setResponseCode(416) .setHeader("Content-Range", "bytes */0") .setHeader("Accept-Ranges", "none") + transcode && decalage > 0 -> MockResponse() + .setHeader("Accept-Ranges", "none") + .setChunkedBody(Buffer().write(segmentDe(decalage)), 4096) + transcode -> MockResponse() .setHeader("Accept-Ranges", "none") .setChunkedBody(Buffer().write(contenu), 4096) @@ -289,6 +298,34 @@ class RemoteMediaCacheTest { ) } + @Test + fun `un segment ne se sert pas du morceau deja en cache`() { + // Le cache est posé avant le résolveur. Sous la clé du morceau, un + // segment se verrait servir le morceau depuis 0:00 pendant que l'écran + // afficherait l'instant demandé. + val contenu = octetsAudio() + servirCommeLeServeur(contenu) + val factory = mediaCache.dataSourceFactory(resolver) + lire(factory.createDataSource(), specDe("piste-1", "opus", 96)) + + val segment = lire(factory.createDataSource(), specDe("piste-1", "opus", 96, offsetMs = 133_000L)) + + assertArrayEquals(segmentDe(133_000L), segment) + assertEquals("le segment est allé au serveur", 2, server.requestCount) + } + + @Test + fun `un segment ne s'ecrit pas dans le cache`() = runTest { + // Lu jusqu'au bout, son reste rangé sous la clé du morceau serait + // resservi à qui demande le morceau entier. + servirCommeLeServeur(octetsAudio()) + val factory = mediaCache.dataSourceFactory(resolver) + + lire(factory.createDataSource(), specDe("piste-1", "opus", 96, offsetMs = 133_000L)) + + assertEquals(0L, mediaCache.usedBytes()) + } + @Test fun `un fichier local ne passe pas par le cache`() { // Il est déjà sur le disque : le recopier doublerait sa place. @@ -376,6 +413,9 @@ private const val OCTETS_ECOUTES = 8_000 private fun octetsAudio() = ByteArray(64_000) { (it % 251).toByte() } +/** Ce que le serveur rend d'un flux décalé : reconnaissable, et autre que le morceau. */ +private fun segmentDe(offsetMs: Long) = "segment à partir de $offsetMs ms".toByteArray() + /** * Sert un fichier temporaire derrière une URI `content://`. * diff --git a/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt index 50ff018..b0820c1 100644 --- a/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt +++ b/app/src/test/java/app/waveflow/playback/RemoteStreamResolverTest.kt @@ -122,4 +122,26 @@ class RemoteStreamResolverTest { assertEquals(StreamRendering.ORIGINAL, catalog.lastRendering) } + + @Test + fun `le decalage demande au serveur est celui du marqueur`() = runTest { + // Lu au même endroit que le rendu : c'est sur ce marqueur que la chaîne + // a décidé de ne pas passer par le cache. + val catalog = FakeCatalogApi() + + resolver(catalog).resolveDataSpec( + specOf("waveflow://track/c07f8d98?format=opus&bitrate=96&offset_ms=133000"), + ) + + assertEquals(133_000L, catalog.lastOffsetMs) + } + + @Test + fun `un marqueur sans decalage part du debut`() = runTest { + val catalog = FakeCatalogApi() + + resolver(catalog).resolveDataSpec(specOf("waveflow://track/c07f8d98?format=opus&bitrate=96")) + + assertEquals(0L, catalog.lastOffsetMs) + } } diff --git a/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt new file mode 100644 index 0000000..cfb7870 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt @@ -0,0 +1,560 @@ +package app.waveflow.playback + +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.Timeline +import androidx.media3.common.Tracks +import androidx.media3.common.util.Util +import app.waveflow.model.StreamRendering +import app.waveflow.testing.remoteSong +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * L'enveloppe face à un lecteur factice dont on pilote l'état. + * + * Robolectric ne décode pas l'Opus : aucune piste n'y joue réellement. Ce qui + * s'éprouve ici, c'est ce que l'enveloppe fait de ce que le lecteur enveloppé + * lui dit — et ce qu'elle lui demande. Les renvois R1 à R13 pointent vers + * `docs/deplacement-dans-un-transcodage.md`. + */ +@RunWith(RobolectricTestRunner::class) +class TranscodeSeekingPlayerTest { + + private val faux = FauxLecteur() + private val lecteur = TranscodeSeekingPlayer(faux) + + private fun transcode(id: String = "a") = + remoteSong(id = id, durationMs = DUREE_MS).toMediaItem().withRendering(StreamRendering("opus", 96)) + + private fun original(id: String = "b") = remoteSong(id = id, durationMs = DUREE_MS).toMediaItem() + + private fun jouer(vararg items: MediaItem, index: Int = 0) { + lecteur.setMediaItems(items.toList(), index, 0L) + ecouler() + } + + private fun ecouler() = shadowOf(Looper.getMainLooper()).idle() + + private val Player.fenetreCourante: Timeline.Window + get() = currentTimeline.getWindow(currentMediaItemIndex, Timeline.Window()) + + private val FauxLecteur.dernierDecalage: Long + get() = streamOffsetOfRemoteUri(remplacements.last().second.localConfiguration!!.uri) + + @Test + fun `un transcodage sans duree annonce celle du catalogue`() { + // R3 : sans elle, le curseur de l'écran reste désactivé. + jouer(transcode()) + + assertEquals(DUREE_MS, lecteur.duration) + } + + @Test + fun `un transcodage est annonce deplacable, commande de saut comprise`() { + // R4 : sans la commande, la notification et la voiture ne proposent + // aucun saut, et celui d'un contrôleur est ignoré. + jouer(transcode()) + + assertTrue(lecteur.isCurrentMediaItemSeekable) + assertTrue(lecteur.isCommandAvailable(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)) + } + + @Test + fun `l'ordre aleatoire reste celui du lecteur enveloppe`() { + // R1 : reconstruire la timeline au lieu de l'envelopper perdrait l'ordre + // aléatoire — « suivant » suivrait l'ordre de la file. + faux.ordreAleatoire = listOf(0, 2, 1) + jouer(transcode("a"), transcode("b"), transcode("c")) + + lecteur.shuffleModeEnabled = true + ecouler() + + assertEquals(2, lecteur.nextMediaItemIndex) + } + + @Test + fun `un original se deplace nativement`() { + faux.deplacable = { true } + jouer(original()) + + lecteur.seekTo(30_000L) + ecouler() + + assertEquals(listOf(0 to 30_000L), faux.sauts) + assertEquals(0, faux.remplacements.size) + } + + @Test + fun `un transcodage entier en cache se deplace nativement`() { + // R7 : relancer ce qui se déplace déjà rouvrirait un transcodage pour + // rien, et contournerait le cache. + faux.deplacable = { true } + jouer(transcode()) + + lecteur.seekTo(30_000L) + ecouler() + + assertEquals(listOf(0 to 30_000L), faux.sauts) + assertEquals(0, faux.remplacements.size) + } + + @Test + fun `un transcodage non deplacable se relance au decalage demande`() { + jouer(transcode()) + + lecteur.seekTo(133_000L) + ecouler() + + val (index, item) = faux.remplacements.single() + assertEquals(0, index) + assertEquals(133_000L, streamOffsetOfRemoteUri(item.localConfiguration!!.uri)) + assertEquals(StreamRendering("opus", 96), renderingOfRemoteUri(item.localConfiguration!!.uri)) + } + + @Test + fun `pendant la relance, la position est deja la cible`() { + // R6 : le flux décalé n'a encore rien rendu, le curseur ne doit pas + // revenir en arrière en l'attendant. + jouer(transcode()) + + lecteur.seekTo(133_000L) + ecouler() + + assertEquals(133_000L, lecteur.currentPosition) + } + + @Test + fun `la position est le decalage plus la position dans le flux`() { + // R2. + jouer(transcode()) + lecteur.seekTo(133_000L) + ecouler() + + faux.avancer(5_000L) + + assertEquals(138_000L, lecteur.currentPosition) + } + + @Test + fun `un segment annonce la duree du morceau entier`() { + // R3 : le segment ne connaît que ce qui reste. + faux.dureeUs = { item -> + if (streamOffsetOfRemoteUri(item.localConfiguration!!.uri) > 0L) Util.msToUs(112_000L) else C.TIME_UNSET + } + jouer(transcode()) + + lecteur.seekTo(133_000L) + ecouler() + + assertEquals(DUREE_MS, lecteur.duration) + } + + @Test + fun `une fois un segment en cours, tout saut est une relance`() { + // R7 : la timeline du segment commence au décalage ; un saut natif y + // atterrirait d'autant plus loin. + jouer(transcode()) + lecteur.seekTo(133_000L) + ecouler() + faux.deplacable = { true } + + lecteur.seekTo(60_000L) + ecouler() + + assertEquals(2, faux.remplacements.size) + assertEquals(60_000L, faux.dernierDecalage) + assertEquals(0, faux.sauts.size) + } + + @Test + fun `un saut au-dela de la fin est borne au morceau`() { + // R8 : le serveur refuse un décalage au-delà de la durée. + jouer(transcode()) + + lecteur.seekTo(DUREE_MS + 60_000L) + ecouler() + + assertEquals(DUREE_MS - 1L, faux.dernierDecalage) + } + + @Test + fun `une relance n'est pas un changement de piste`() { + // R5 : l'historique compterait deux fois une piste déjà écoutée. + jouer(transcode()) + val transitions = mutableListOf() + lecteur.addListener( + object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + transitions += reason + } + }, + ) + val avant = lecteur.fenetreCourante.uid + + lecteur.seekTo(133_000L) + ecouler() + + assertEquals("la relance a bien eu lieu", 1, faux.remplacements.size) + assertEquals(emptyList(), transitions) + assertEquals(avant, lecteur.fenetreCourante.uid) + } + + @Test + fun `une relance s'annonce comme un saut a la position demandee`() { + // R5 : le lecteur enveloppé, lui, annonce une piste retirée. + jouer(transcode()) + val discontinuites = mutableListOf>() + lecteur.addListener( + object : Player.Listener { + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + discontinuites += reason to newPosition.positionMs + } + }, + ) + + lecteur.seekTo(133_000L) + ecouler() + + assertEquals(listOf(Player.DISCONTINUITY_REASON_SEEK to 133_000L), discontinuites) + } + + @Test + fun `la piste exposee reste le marqueur sans decalage`() { + // R10 : le décalage ne regarde que la chaîne de lecture. + jouer(transcode()) + + lecteur.seekTo(133_000L) + ecouler() + + assertEquals(transcode(), lecteur.currentMediaItem) + } + + @Test + fun `precedent, passe le debut d'un segment, revient au debut du morceau`() { + // R13 : à 2 s d'un segment commencé à 2:10, on est à 2:12. L'ExoPlayer, + // sur sa position brute, reculerait d'une piste. + jouer(original(), transcode(), index = 1) + lecteur.seekTo(130_000L) + ecouler() + faux.avancer(2_000L) + + lecteur.seekToPrevious() + ecouler() + + assertEquals("la piste courante ne change pas", 1, faux.currentMediaItemIndex) + assertEquals(0L, faux.dernierDecalage) + } + + @Test + fun `precedent, au tout debut, recule d'une piste`() { + // Le pendant du précédent : la réécriture ne doit pas empêcher de + // reculer quand on est bien au début. + jouer(original(), transcode(), index = 1) + + lecteur.seekToPrevious() + ecouler() + + assertEquals(0, faux.currentMediaItemIndex) + } + + @Test + fun `reculer se calcule sur la position logique`() { + // R13 : 2:18 moins cinq secondes, c'est 2:13 — pas le début du segment. + jouer(transcode()) + lecteur.seekTo(130_000L) + ecouler() + faux.avancer(8_000L) + + lecteur.seekBack() + ecouler() + + assertEquals(133_000L, faux.dernierDecalage) + } + + @Test + fun `repeter un titre joue depuis un segment repart du debut du morceau`() { + // R12 : laisser l'ExoPlayer répéter le flux rejouerait les dernières + // secondes du morceau sans fin. + faux.dureeUs = { item -> + if (streamOffsetOfRemoteUri(item.localConfiguration!!.uri) > 0L) Util.msToUs(112_000L) else C.TIME_UNSET + } + jouer(transcode()) + lecteur.repeatMode = Player.REPEAT_MODE_ONE + lecteur.seekTo(133_000L) + ecouler() + + faux.finirLeFlux() + + assertEquals(0L, faux.dernierDecalage) + assertEquals(0L, lecteur.currentPosition) + } + + @Test + fun `repeter un titre joue depuis le debut ne relance rien`() { + // Le pendant du précédent : sans segment, la répétition de l'ExoPlayer + // est la bonne, et la remplacer couperait le son pour rien. + faux.dureeUs = { Util.msToUs(DUREE_MS) } + jouer(transcode()) + lecteur.repeatMode = Player.REPEAT_MODE_ONE + + faux.finirLeFlux() + + assertEquals(0, faux.remplacements.size) + } + + @Test + fun `une piste relancee qu'on quitte retrouve son marqueur nu`() { + // R14 : sinon, y revenir — piste suivante, file rejouée — la ferait + // repartir en plein milieu. + faux.dureeUs = { item -> + if (streamOffsetOfRemoteUri(item.localConfiguration!!.uri) > 0L) Util.msToUs(112_000L) else C.TIME_UNSET + } + jouer(transcode("a"), transcode("b")) + lecteur.seekTo(133_000L) + ecouler() + + faux.finirLeFlux() + + assertEquals("on est passé au morceau suivant", 1, faux.currentMediaItemIndex) + assertEquals(0, faux.remplacements.last().first) + assertEquals(0L, faux.dernierDecalage) + } + + @Test + fun `revenir a la position par defaut repart du debut du morceau`() { + // `seekToDefaultPosition()` sans rang, ce qu'un contrôleur envoie pour + // revenir au début. Laissé à l'ExoPlayer, il revient au début du flux — + // le milieu du morceau — en gardant le décalage. + // + // La variante avec rang, celle de la file, passe par une autre commande, + // déjà réécrite : viser celle-là rendrait ce test creux. + jouer(transcode()) + lecteur.seekTo(133_000L) + ecouler() + + lecteur.seekToDefaultPosition() + ecouler() + + assertEquals(0L, faux.dernierDecalage) + assertEquals(0L, lecteur.currentPosition) + } + + @Test + fun `une position inconnue reste inconnue`() { + // R2 : lui ajouter le décalage publierait un instant plausible et faux. + assertEquals(C.TIME_UNSET, logicalPositionMs(C.TIME_UNSET, 133_000L, DUREE_MS)) + } + + @Test + fun `une position logique ne depasse pas la fin du morceau`() { + // R2 : un flux transcodé peut durer un peu plus que le catalogue. + assertEquals(DUREE_MS, logicalPositionMs(112_400L, 133_000L, DUREE_MS)) + } + + private companion object { + const val DUREE_MS = 245_000L + } +} + +/** Une piste du faux lecteur, avec ce que le vrai en saurait. */ +private class Piste(val item: MediaItem, val deplacable: Boolean, val dureeUs: Long) { + /** Neufs à chaque piste, comme ceux que l'ExoPlayer donne à une piste remplacée. */ + val fenetre = Any() + val periode = Any() +} + +/** + * Un lecteur enveloppé dont on pilote l'état, comme l'ExoPlayer le tiendrait. + * + * Il se comporte comme le vrai là où l'enveloppe en dépend : une piste sans + * longueur n'est pas déplaçable et perd ses commandes de saut, une piste + * remplacée reçoit un **nouvel** identifiant, et sa timeline porte son propre + * ordre aléatoire. + */ +private class FauxLecteur : SimpleBasePlayer(Looper.getMainLooper()) { + + /** Déplaçable si le vrai lecteur connaissait sa longueur ; un transcodage en direct ne l'est pas. */ + var deplacable: (MediaItem) -> Boolean = { !it.isRemoteTranscode() } + var dureeUs: (MediaItem) -> Long = { C.TIME_UNSET } + + /** L'ordre de lecture aléatoire, par rangs ; celui de la file s'il n'est pas donné. */ + var ordreAleatoire: List? = null + + val sauts = mutableListOf>() + val remplacements = mutableListOf>() + + private var pistes = listOf() + private var index = 0 + private var positionMs = 0L + private var melange = false + + /** + * Avance la lecture, et laisse l'enveloppe l'apprendre. + * + * Un saut de position se signale en deux temps : la discontinuité tout de + * suite, `onEvents` au passage suivant de la boucle. Entre les deux, + * l'enveloppe tient encore des positions figées à l'ancienne valeur. + */ + fun avancer(ms: Long) { + positionMs = ms + invalidateState() + shadowOf(Looper.getMainLooper()).idle() + } + + /** + * Joue le flux courant jusqu'au bout, puis enchaîne comme le vrai lecteur : + * la même piste si elle se répète, la suivante sinon. + * + * C'est le dépassement de la durée qui fait qualifier la discontinuité de + * transition automatique, et non un drapeau posé à la main. + */ + fun finirLeFlux() { + val courante = pistes[index] + positionMs = Util.usToMs(courante.dureeUs) + invalidateState() + if (repetition != Player.REPEAT_MODE_ONE) index += 1 + positionMs = 0L + invalidateState() + shadowOf(Looper.getMainLooper()).idle() + } + + override fun getState(): State { + val courante = pistes.getOrNull(index) + val commandes = Player.Commands.Builder().addAllCommands().apply { + if (courante?.deplacable != true) { + removeAll(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, COMMAND_SEEK_BACK, COMMAND_SEEK_FORWARD) + } + }.build() + return State.Builder() + .setAvailableCommands(commandes) + .setPlaylist(TimelineDeTest(pistes, ordreAleatoire ?: pistes.indices.toList()), Tracks.EMPTY, null) + .setCurrentMediaItemIndex(index) + .setContentPositionMs(positionMs) + .setPlaybackState(if (pistes.isEmpty()) STATE_IDLE else STATE_READY) + .setPlayWhenReady(true, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setShuffleModeEnabled(melange) + .setRepeatMode(repetition) + .setMaxSeekToPreviousPositionMs(3_000L) + .setSeekBackIncrementMs(5_000L) + .build() + } + + private var repetition = Player.REPEAT_MODE_OFF + + override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> { + repetition = repeatMode + return Futures.immediateVoidFuture() + } + + private fun piste(item: MediaItem) = Piste(item, deplacable(item), dureeUs(item)) + + override fun handleSetMediaItems( + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture<*> { + pistes = mediaItems.map(::piste) + index = startIndex.coerceAtLeast(0) + positionMs = 0L + return Futures.immediateVoidFuture() + } + + override fun handleReplaceMediaItems( + fromIndex: Int, + toIndex: Int, + mediaItems: MutableList, + ): ListenableFuture<*> { + remplacements += fromIndex to mediaItems.single() + pistes = pistes.toMutableList().apply { this[fromIndex] = piste(mediaItems.single()) } + if (fromIndex == index) positionMs = 0L + return Futures.immediateVoidFuture() + } + + override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> { + melange = shuffleModeEnabled + return Futures.immediateVoidFuture() + } + + override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + if (seekCommand == COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) { + index = (index - 1).coerceAtLeast(0) + this.positionMs = 0L + } else { + sauts += mediaItemIndex to positionMs + index = mediaItemIndex + this.positionMs = positionMs.takeIf { it != C.TIME_UNSET } ?: 0L + } + return Futures.immediateVoidFuture() + } +} + +/** La timeline du faux lecteur : une période par piste, et l'ordre aléatoire qu'on lui donne. */ +private class TimelineDeTest(private val pistes: List, private val ordre: List) : Timeline() { + + override fun getWindowCount(): Int = pistes.size + + override fun getPeriodCount(): Int = pistes.size + + override fun getNextWindowIndex(windowIndex: Int, repeatMode: Int, shuffleModeEnabled: Boolean): Int = + if (shuffleModeEnabled && repeatMode == Player.REPEAT_MODE_OFF) { + ordre.getOrNull(ordre.indexOf(windowIndex) + 1) ?: C.INDEX_UNSET + } else { + super.getNextWindowIndex(windowIndex, repeatMode, shuffleModeEnabled) + } + + override fun getPreviousWindowIndex(windowIndex: Int, repeatMode: Int, shuffleModeEnabled: Boolean): Int = + if (shuffleModeEnabled && repeatMode == Player.REPEAT_MODE_OFF) { + ordre.getOrNull(ordre.indexOf(windowIndex) - 1) ?: C.INDEX_UNSET + } else { + super.getPreviousWindowIndex(windowIndex, repeatMode, shuffleModeEnabled) + } + + override fun getFirstWindowIndex(shuffleModeEnabled: Boolean): Int = + if (shuffleModeEnabled && ordre.isNotEmpty()) ordre.first() else super.getFirstWindowIndex(false) + + override fun getLastWindowIndex(shuffleModeEnabled: Boolean): Int = + if (shuffleModeEnabled && ordre.isNotEmpty()) ordre.last() else super.getLastWindowIndex(false) + + override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + val piste = pistes[windowIndex] + return window.set( + /* uid = */ piste.fenetre, + /* mediaItem = */ piste.item, + /* manifest = */ null, + /* presentationStartTimeMs = */ C.TIME_UNSET, + /* windowStartTimeMs = */ C.TIME_UNSET, + /* elapsedRealtimeEpochOffsetMs = */ C.TIME_UNSET, + /* isSeekable = */ piste.deplacable, + /* isDynamic = */ false, + /* liveConfiguration = */ null, + /* defaultPositionUs = */ 0L, + /* durationUs = */ piste.dureeUs, + /* firstPeriodIndex = */ windowIndex, + /* lastPeriodIndex = */ windowIndex, + /* positionInFirstPeriodUs = */ 0L, + ) + } + + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + val piste = pistes[periodIndex] + return period.set(piste.periode, piste.periode, periodIndex, piste.dureeUs, 0L) + } + + override fun getIndexOfPeriod(uid: Any): Int = pistes.indexOfFirst { it.periode == uid } + + override fun getUidOfPeriod(periodIndex: Int): Any = pistes[periodIndex].periode +} diff --git a/app/src/test/java/app/waveflow/testing/ServerFakes.kt b/app/src/test/java/app/waveflow/testing/ServerFakes.kt index 0460fae..8bd2fed 100644 --- a/app/src/test/java/app/waveflow/testing/ServerFakes.kt +++ b/app/src/test/java/app/waveflow/testing/ServerFakes.kt @@ -178,9 +178,11 @@ class FakeCatalogApi( accessToken: String, trackId: String, rendering: StreamRendering, + offsetMs: Long, ): String { record(serverUrl, accessToken, null) lastRendering = rendering + lastOffsetMs = offsetMs return "$serverUrl/api/v2/stream/ticket-$trackId" } @@ -188,6 +190,10 @@ class FakeCatalogApi( var lastRendering: StreamRendering? = null private set + /** Le décalage demandé au dernier ticket. */ + var lastOffsetMs: Long? = null + private set + private suspend fun record(serverUrl: String, accessToken: String, page: Pair?) { calls++ lastServerUrl = serverUrl @@ -316,6 +322,7 @@ class PagingCatalogApi( accessToken: String, trackId: String, rendering: StreamRendering, + offsetMs: Long, ): String = "$serverUrl/api/v2/stream/ticket-$trackId" private fun failIfDue(call: Int) { diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md new file mode 100644 index 0000000..f8b2b8c --- /dev/null +++ b/docs/deplacement-dans-un-transcodage.md @@ -0,0 +1,199 @@ +# Se déplacer dans un morceau transcodé + +Règles arrêtées **avant le code**, à la demande de l'utilisateur (11/09). Chaque +règle dit ce qu'on doit observer, pour pouvoir s'éprouver par un test. Une règle +qui change se change ici d'abord. + +Ce qui est dit de Media3 a été lu dans les sources de la 1.11.0. + +## Le problème + +Un transcodage en direct n'a ni longueur ni plages : le serveur répond +`Accept-Ranges: none` et refuse en 416 toute plage qui ne part pas du premier +octet (`waveflow-server`, `src/media.rs`). ExoPlayer, lui, se déplace par plages. + +Ce qui en découle aujourd'hui : + +- **Media3 tient la piste pour non déplaçable, et sans durée.** Faute de longueur, + l'extracteur Ogg pose un `UnseekableOggSeeker`, dont la `SeekMap` est + `Unseekable(C.TIME_UNSET)` (`StreamReader`). +- **Le curseur est désactivé.** Sans durée, `Media3PlaybackController` publie + `durationMs = 0`, et le curseur de `NowPlayingScreen` porte + `enabled = hasDuration`. +- **La notification et la voiture ne peuvent pas se déplacer non plus.** + `Util.getAvailableCommands` n'accorde `COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM` qu'à + une piste déplaçable. + +Le serveur offre l'autre voie : `offset_ms`, un décalage **temporel**. Il relance +ffmpeg avec `-ss` et sert le reste du morceau à partir de là. Il le refuse sur +`raw` (422) et au-delà de la durée de la piste (422), et ne sert jamais un flux +décalé depuis son cache. + +## Le principe : une timeline logique + +> Le flux reçu peut commencer à 2:13. Pour le reste d'Android, le morceau commence +> toujours à 0:00 et dure sa durée entière. + +Vocabulaire : + +- **flux** : ce qu'ExoPlayer lit réellement ; +- **segment** : un flux demandé avec `offset_ms > 0` ; +- **décalage** : l'instant du morceau où le flux commence, `0` hors segment ; +- **position logique** : décalage + position dans le flux ; +- **relance** : remplacer le flux de la piste courante par un autre, à un autre + décalage. + +## Les règles + +### Un seul endroit + +**R1. La correction vit dans une enveloppe, et tout le service lui parle.** Un +`ForwardingSimpleBasePlayer` enveloppe l'ExoPlayer. La session, et tout ce que +`PlaybackService` branche sur le lecteur — historique, boucle A-B, minuterie, +vitesse — reçoivent l'enveloppe, **jamais l'ExoPlayer**. Seuls l'enveloppe et la +chaîne de lecture connaissent le décalage. + +`ForwardingSimpleBasePlayer` plutôt que `ForwardingPlayer` : on corrige un +**état** (`getState()`), et les événements que voient les contrôleurs en sont +déduits par Media3. Avec `ForwardingPlayer`, il faudrait corriger chaque getter +**et** chaque événement, et l'oubli d'un seul ramène 0:00 dans la notification — +le ticket #135 de Resonus. + +Mais `ForwardingSimpleBasePlayer` **transmet** beaucoup tel quel, et ce qu'il +transmet raisonne sur l'ExoPlayer. R5 et R13 disent ce qui doit être réécrit. + +Et la timeline se corrige **en enveloppant celle de l'ExoPlayer** +(`ForwardingTimeline`), jamais en reposant la liste de lecture sous forme de +liste (`setPlaylist(List)`). Celle-ci fabrique une `PlaylistTimeline`, qui ignore +l'ordre aléatoire — « TODO: Support shuffle order » dans la 1.11 : « suivant », +« précédent » et l'état des boutons de la notification suivraient alors l'ordre +de la file, lecture aléatoire ou non. + +### Ce que voit Android + +**R2. Position.** Position de contenu et position tamponnée sont logiques : +décalage + position dans le flux. Deux gardes : + +- une position **inconnue** (`C.TIME_UNSET`) reste inconnue — on n'y ajoute pas + le décalage, ce qui publierait un instant plausible et faux ; +- une position logique reste dans `[0, durée]`. Un flux transcodé peut durer + quelques millisecondes de plus que la durée du catalogue : la dépasser ferait + afficher une position au-delà de la fin. + +**R3. Durée.** La durée exposée est celle du catalogue quand un segment joue, ou +quand le flux n'en annonce aucune — c'est le cas de tout transcodage en direct. +`RemoteSong.durationMs` existe mais `toMediaItem()` ne la transmet pas : elle +voyagera dans `MediaMetadata.durationMs`. + +**R4. Déplaçable.** Une piste distante transcodée est annoncée déplaçable, pour +que la commande de saut existe pour tous les contrôleurs — application, +notification, Android Auto. + +**R5. La relance n'est pas un changement de piste.** Ni la session ni les +écouteurs du service ne voient de transition. + +Deux raisons de l'écrire : + +- `ListeningCounter.trackChanged` remet son compte à zéro à **chaque** appel, + même pour la même piste. Une relance vue comme une transition ferait compter + deux fois un morceau déjà écouté. +- Relancer, c'est remplacer la piste courante de l'ExoPlayer par un marqueur + différent. `ExoPlayerImpl.replaceMediaItems` ne sait pas mettre à jour une + source dont l'URI change : il retire l'ancienne et ajoute la nouvelle, d'où un + **nouvel identifiant de piste** et un saut de raison `DISCONTINUITY_REASON_REMOVE`. + `SimpleBasePlayer` déduit les transitions de ces identifiants. L'enveloppe + garde donc à la piste relancée **l'identifiant qu'elle avait**, et présente le + saut comme un déplacement (`DISCONTINUITY_REASON_SEEK`) à la position logique. + Rattacher l'identifiant juste après le remplacement suffit : Media3 livre + `onEvents` par un message posté, et l'état n'est relu qu'ensuite. + +**R6. Pendant la relance, la position est déjà la cible.** Le décalage est posé +avant de relancer : le curseur ne revient pas en arrière le temps que le flux +arrive. + +### Quand relancer + +**R7. Natif quand ExoPlayer le peut, relance sinon.** Un saut se fait nativement +si la piste courante du lecteur enveloppé est déplaçable — l'original, un +transcodage entier déjà en cache. Sinon, relance avec `offset_ms`. Une fois un +segment en cours, **tout** saut est une relance : le segment n'a pas de longueur +non plus. + +**R8. Jamais de `offset_ms` hors transcodage**, et un décalage borné à +`[0, durée[` — le serveur refuse le reste en 422. Un saut à la toute fin se +traite comme la fin du morceau. + +**R9. Seul le dernier saut compte.** Chaque relance occupe un créneau de +transcodage côté serveur (`per_user_limit`). Des sauts rapprochés ne doivent pas +en empiler ; le 429 qui en résulterait relève du chantier 429. + +### Le décalage et le cache + +**R10. Le décalage voyage dans le marqueur**, comme le rendu depuis la #51 : +`waveflow://track/?format=opus&bitrate=96&offset_ms=133000`. +`RemoteStreamResolver` le relit. Aucun état partagé entre le calcul de la clé de +cache et la construction de l'URL : c'est le motif « vérifier puis agir » que la +#51 a évité par construction. + +**R11. Un segment contourne `CacheDataSource`**, avant toute lecture et toute +écriture. Une clé distincte ou une écriture interdite ne suffisent pas : le cache +est posé **avant** le résolveur, et un segment qui s'y présenterait sous la clé du +morceau entier se verrait servir les octets du morceau déjà en cache — l'audio +repartirait de 0:00 pendant que l'enveloppe publierait la position demandée. La +chaîne aiguille donc un marqueur porteur de `offset_ms` directement vers le +résolveur. Mettre les segments en cache, sous une clé qui porte le décalage, +viendra éventuellement plus tard — pas dans cette première version. + +### Répéter, revenir, passer + +**R12. Répéter un titre repart du début du morceau**, jamais du début du +segment. Laisser `REPEAT_MODE_ONE` à ExoPlayer pendant un segment rejouerait ses +dernières secondes sans fin — le piège payé par Resonus (`applyLoop`). + +**R13. « Précédent », « reculer » et « avancer » jugent sur la position +logique.** `ForwardingSimpleBasePlayer.handleSeek` les transmet tels quels +(`seekToPrevious`, `seekBack`, `seekForward`), et l'ExoPlayer les calcule sur sa +position **brute** : à 2:13 d'un segment commencé à 2:10, il se croirait à 0:03 +et reculerait d'une piste au lieu de revenir au début. L'enveloppe les réécrit en +sauts calculés sur la position logique — `maxSeekToPreviousPositionMs` pour +« précédent », les incréments pour « reculer » et « avancer ». Revenir au début, +c'est un décalage `0`. + +**R14. Changer de piste remet le décalage à zéro.** Un segment n'est jamais +reposé sur une autre piste, ni sur la même piste rejouée depuis la file. + +### Ce qui lit une position + +**R15. La boucle A-B lit et rembobine par l'enveloppe.** Sur un transcodage, +chaque tour de boucle est une relance : une latence audible, **acceptée** dans +cette première version. + +**R16. Toute reprise de position passera par l'enveloppe.** Il n'en existe aucune +aujourd'hui (pas de `onPlaybackResumption`). Celle qui viendra passera par un +saut de l'enveloppe, et non par une position de départ posée sur l'ExoPlayer. + +## Ce que cette première version ne couvre pas + +- **La coupure réseau pendant un transcodage.** Media3 reprend à l'octet atteint, + et le serveur refuse cette plage. La même relance l'y ramènera, au décalage de + la position logique courante — dans une PR suivante, une fois l'enveloppe en + place. +- **Le cache des segments** (R11). +- **Le 429 à la relance** (R9) : chantier suivant. + +## Comment l'éprouver + +Robolectric ne décode pas l'Opus : aucune piste n'y joue, `isPlaying` n'y est +jamais vrai. L'enveloppe s'éprouve donc face à un **lecteur enveloppé factice** +dont on pilote l'état : piste déplaçable ou non, position, durée, identifiants, +fin de flux. Les règles R2 à R8 et R10 à R14 s'y vérifient une à une. **R9 fait exception : +rien ne garantit encore qu'un saut chasse le précédent.** Remplacer la piste fait +bien abandonner le chargement en cours à l'ExoPlayer, mais cette coalescence +n'est ni écrite ni éprouvée ici ; elle vient avec le 429, où elle a son sens. **R1 se vérifie en plus sur la +vraie chaîne** service + `MediaController` (`PlaybackServiceSeekTest`) : c'est la +session qui doit annoncer la durée du catalogue et la commande de saut, pas +seulement l'enveloppe. R10 et R11 s'éprouvent sur la chaîne de +lecture réelle face à un `MockWebServer`, comme `RemoteMediaCacheTest`. + +Le jeu réel — un saut entendu au bon endroit, la notification et la voiture qui +suivent — reste **à valider sur appareil**.