From 7a4c2ffa036634b680d9d2b401be90efad8b1121 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 22:41:49 +0200 Subject: [PATCH 01/10] =?UTF-8?q?docs:=20arr=C3=AAter=20les=20r=C3=A8gles?= =?UTF-8?q?=20du=20d=C3=A9placement=20dans=20un=20transcodage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seize règles écrites avant le code, chacune observable par un test : une enveloppe unique autour de l'ExoPlayer, position et durée logiques, relance par offset_ms quand le flux n'est pas déplaçable, décalage porté par le marqueur et tenu hors du cache, répétition et « précédent » jugés sur le morceau entier. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- docs/deplacement-dans-un-transcodage.md | 154 ++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/deplacement-dans-un-transcodage.md diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md new file mode 100644 index 0000000..7263f63 --- /dev/null +++ b/docs/deplacement-dans-un-transcodage.md @@ -0,0 +1,154 @@ +# 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. + +## 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, lu dans le code : + +- **Le curseur est désactivé.** Sans longueur, ExoPlayer ne connaît pas la durée ; + `Media3PlaybackController` publie alors `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, et un saut venu d'un contrôleur sans cette commande est + ignoré. + +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. + +## 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. + +### Ce que voit Android + +**R2. Position.** Position de contenu et position tamponnée sont logiques : +décalage + position dans le flux. + +**R3. Durée.** La durée exposée est celle du catalogue quand un segment joue, ou +quand le flux n'en annonce aucune. `RemoteSong.durationMs` existe mais +`toMediaItem()` ne la transmet pas : elle voyagera dans +`MediaMetadata.durationMs`. Un segment annonce, au mieux, la durée de ce qui +reste. + +**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. `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é. + +**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 ne touche pas au cache**, ni en lecture ni en écriture. Il ne +s'y range **jamais** sous la clé du morceau entier. Plus tard, éventuellement, +sous une clé qui porte le décalage — 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 » décide sur la position logique.** Recommencer ou reculer +d'une piste se juge sur `maxSeekToPreviousPositionMs` et la position logique : à +2:13 d'un segment commencé à 2:10, on est à 2:13, pas à 0:03. 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, fin de flux. Les +règles R2 à R14 s'y vérifient une à une. R1 et R5 se vérifient en plus sur la +vraie chaîne service + `MediaController` (voir `PlaybackServiceQualityTest`) : +c'est la session qui doit voir la position logique, 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**. From ad3c1f4ebd54a8840f29af7d63f6a0832ba16566 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 22:45:03 +0200 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20pr=C3=A9ciser=20trois=20r=C3=A8gl?= =?UTF-8?q?es=20d'apr=C3=A8s=20les=20sources=20de=20Media3=201.11.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'extracteur Ogg sans longueur rend bien la piste non déplaçable et sans durée. ForwardingSimpleBasePlayer transmet « précédent », « reculer » et « avancer » à l'ExoPlayer, qui en juge sur la position brute : l'enveloppe les réécrit. Et remplacer la piste courante change son identifiant, dont SimpleBasePlayer déduit les transitions : l'enveloppe le garde stable. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- docs/deplacement-dans-un-transcodage.md | 70 ++++++++++++++++--------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md index 7263f63..163a7c2 100644 --- a/docs/deplacement-dans-un-transcodage.md +++ b/docs/deplacement-dans-un-transcodage.md @@ -4,21 +4,25 @@ Règles arrêtées **avant le code**, à la demande de l'utilisateur (11/09). Ch 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, lu dans le code : +Ce qui en découle aujourd'hui : -- **Le curseur est désactivé.** Sans longueur, ExoPlayer ne connaît pas la durée ; - `Media3PlaybackController` publie alors `durationMs = 0`, et le curseur de - `NowPlayingScreen` porte `enabled = hasDuration`. +- **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, et un saut venu d'un contrôleur sans cette commande est - ignoré. + 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 @@ -35,7 +39,9 @@ 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. +- **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 @@ -53,26 +59,38 @@ 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. + ### Ce que voit Android **R2. Position.** Position de contenu et position tamponnée sont logiques : décalage + position dans le flux. **R3. Durée.** La durée exposée est celle du catalogue quand un segment joue, ou -quand le flux n'en annonce aucune. `RemoteSong.durationMs` existe mais -`toMediaItem()` ne la transmet pas : elle voyagera dans -`MediaMetadata.durationMs`. Un segment annonce, au mieux, la durée de ce qui -reste. +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. `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é. +é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. **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 @@ -112,9 +130,13 @@ sous une clé qui porte le décalage — pas dans cette première version. 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 » décide sur la position logique.** Recommencer ou reculer -d'une piste se juge sur `maxSeekToPreviousPositionMs` et la position logique : à -2:13 d'un segment commencé à 2:10, on est à 2:13, pas à 0:03. Revenir au début, +**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 @@ -143,12 +165,12 @@ saut de l'enveloppe, et non par une position de départ posée sur l'ExoPlayer. 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, fin de flux. Les -règles R2 à R14 s'y vérifient une à une. R1 et R5 se vérifient en plus sur la -vraie chaîne service + `MediaController` (voir `PlaybackServiceQualityTest`) : -c'est la session qui doit voir la position logique, pas seulement l'enveloppe. -R10 et R11 s'éprouvent sur la chaîne de lecture réelle face à un `MockWebServer`, -comme `RemoteMediaCacheTest`. +dont on pilote l'état : piste déplaçable ou non, position, durée, identifiants, +fin de flux. Les règles R2 à R14 s'y vérifient une à une. R1 et R5 se vérifient +en plus sur la vraie chaîne service + `MediaController` (voir +`PlaybackServiceQualityTest`) : c'est la session qui doit voir la position +logique, 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**. From 99c95d848359d0be1e39117f6a8d69fdf7f2ad2f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 23:07:23 +0200 Subject: [PATCH 03/10] docs: garder les positions inconnues et contourner le cache avant lecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relecture des règles par CodeRabbit. R2 : une position inconnue ne reçoit pas le décalage, et une position logique reste dans [0, durée]. R11 : un segment ne passe pas par CacheDataSource, qui précède le résolveur — sous la clé du morceau entier, il en lirait les octets depuis 0:00. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- docs/deplacement-dans-un-transcodage.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md index 163a7c2..947ec61 100644 --- a/docs/deplacement-dans-un-transcodage.md +++ b/docs/deplacement-dans-un-transcodage.md @@ -65,7 +65,13 @@ transmet raisonne sur l'ExoPlayer. R5 et R13 disent ce qui doit être réécrit. ### Ce que voit Android **R2. Position.** Position de contenu et position tamponnée sont logiques : -décalage + position dans le flux. +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. @@ -120,9 +126,14 @@ en empiler ; le 429 qui en résulterait relève du chantier 429. 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 ne touche pas au cache**, ni en lecture ni en écriture. Il ne -s'y range **jamais** sous la clé du morceau entier. Plus tard, éventuellement, -sous une clé qui porte le décalage — pas dans cette première version. +**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 From 5667bdef6079d8fcdca45ed4f8a92ccd08df11c6 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 23:18:39 +0200 Subject: [PATCH 04/10] =?UTF-8?q?feat(lecture):=20porter=20le=20d=C3=A9cal?= =?UTF-8?q?age=20du=20marqueur=20jusqu'au=20serveur,=20hors=20du=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première brique du déplacement dans un transcodage (docs, R3, R8, R10, R11). - Une piste distante emporte la durée du catalogue dans sa métadonnée : un transcodage en direct n'en annonce aucune. - Le décalage voyage dans le marqueur (`offset_ms`), comme le rendu ; RemoteStreamResolver le relit et le transmet au ticket. L'original n'en reçoit jamais : le serveur le refuse, et l'original se déplace par plages. - SegmentCacheBypass envoie un segment droit au résolveur. Le cache étant posé avant lui, un segment sous la clé du morceau en lirait les octets depuis 0:00, ou y rangerait son reste. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../app/waveflow/data/remote/CatalogApi.kt | 6 ++ .../waveflow/data/remote/CatalogRepository.kt | 6 +- .../waveflow/data/remote/HttpCatalogApi.kt | 11 ++- .../app/waveflow/playback/MediaItemMapper.kt | 51 +++++++++++-- .../app/waveflow/playback/RemoteMediaCache.kt | 14 +++- .../waveflow/playback/RemoteStreamResolver.kt | 7 +- .../waveflow/playback/SegmentCacheBypass.kt | 54 ++++++++++++++ .../data/remote/HttpCatalogApiTest.kt | 33 +++++++++ .../waveflow/playback/MediaItemMapperTest.kt | 71 +++++++++++++++++++ .../waveflow/playback/RemoteMediaCacheTest.kt | 44 +++++++++++- .../playback/RemoteStreamResolverTest.kt | 22 ++++++ .../java/app/waveflow/testing/ServerFakes.kt | 7 ++ 12 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/app/waveflow/playback/SegmentCacheBypass.kt 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/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/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/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/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/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) { From 47fe56d596aea7550ed03449e889833a4ecb3003 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 23:26:37 +0200 Subject: [PATCH 05/10] docs: la timeline se corrige en enveloppant celle de l'ExoPlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reposer la liste de lecture sous forme de liste fabrique une PlaylistTimeline, qui ignore l'ordre aléatoire dans media3 1.11. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- docs/deplacement-dans-un-transcodage.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md index 947ec61..8e93616 100644 --- a/docs/deplacement-dans-un-transcodage.md +++ b/docs/deplacement-dans-un-transcodage.md @@ -62,6 +62,13 @@ 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 : From 9ac191ff7f681f2ecfd01dbe21d2b4517422c9db Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 23:33:40 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat(lecture):=20pr=C3=A9senter=20=C3=A0?= =?UTF-8?q?=20Android=20la=20timeline=20logique=20d'un=20transcodage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deuxième brique du déplacement dans un transcodage (docs, R1 à R10, R13). TranscodeSeekingPlayer enveloppe l'ExoPlayer, et c'est lui que tiennent la session et tous les écouteurs du service. Un saut dans une piste transcodée non déplaçable remplace son flux par un segment (`offset_ms`) ; l'enveloppe expose la position logique, la durée du catalogue, la piste déplaçable avec ses commandes de saut, et garde à la piste relancée son identifiant — une relance n'est ni un changement de piste ni une piste retirée, mais un saut. « Précédent » et « reculer » sont jugés sur la position logique, que BasePlayer a déjà calculée et que ForwardingSimpleBasePlayer jetait. La timeline est enveloppée (LogicalTimeline) et non reconstruite : une PlaylistTimeline perdrait l'ordre aléatoire. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../app/waveflow/playback/LogicalTimeline.kt | 70 +++ .../app/waveflow/playback/PlaybackService.kt | 8 +- .../playback/TranscodeSeekingPlayer.kt | 209 ++++++++ .../playback/PlaybackServiceSeekTest.kt | 159 ++++++ .../playback/TranscodeSeekingPlayerTest.kt | 467 ++++++++++++++++++ 5 files changed, 912 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/waveflow/playback/LogicalTimeline.kt create mode 100644 app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt create mode 100644 app/src/test/java/app/waveflow/playback/PlaybackServiceSeekTest.kt create mode 100644 app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt 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/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/TranscodeSeekingPlayer.kt b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt new file mode 100644 index 0000000..d8a3772 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt @@ -0,0 +1,209 @@ +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() + + /** + * Vrai le temps d'une relance. + * + * L'ExoPlayer publie ses événements pendant le remplacement même, avec un + * identifiant de piste que l'enveloppe ne sait pas encore rattacher à + * l'ancien : un état lu à cet instant annoncerait un changement de piste. + * On rend donc le dernier état cohérent jusqu'à ce que le lien soit posé. + */ + private var enRelance = false + private var dernierEtat: State? = null + + /** La cible d'une relance, à annoncer comme un saut à la place de la discontinuité qu'elle provoque. */ + private var sautAnnonce: Long? = null + + override fun getState(): State { + dernierEtat?.takeIf { enRelance }?.let { return it } + + 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().also { dernierEtat = it } + } + + 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) + + // 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) { + val avant = player.currentTimeline + val fenetreLogique = fenetresLogiques[fenetre.uid] ?: fenetre.uid + val periodeBrute = avant.getUidOfPeriod(fenetre.firstPeriodIndex) + val periodeLogique = periodesLogiques[periodeBrute] ?: periodeBrute + + enRelance = true + try { + 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 + } finally { + enRelance = false + } + + if (index == player.currentMediaItemIndex) { + // R6 : annoncé tout de suite, le curseur ne revient pas en arrière. + sautAnnonce = decalageMs + } else { + player.seekTo(index, 0L) + } + } + + /** 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/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/TranscodeSeekingPlayerTest.kt b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt new file mode 100644 index 0000000..45ac386 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt @@ -0,0 +1,467 @@ +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 `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() + } + + 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) + .setMaxSeekToPreviousPositionMs(3_000L) + .setSeekBackIncrementMs(5_000L) + .build() + } + + 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 +} From c01b73e0ba80c81fc2f3c6cc911a523e1216f75e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 11 Sep 2026 23:49:00 +0200 Subject: [PATCH 07/10] =?UTF-8?q?feat(lecture):=20r=C3=A9p=C3=A9ter=20un?= =?UTF-8?q?=20morceau=20entier,=20et=20rendre=20son=20marqueur=20nu=20?= =?UTF-8?q?=C3=A0=20une=20piste=20quitt=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Troisième brique du déplacement dans un transcodage (docs, R12 et R14). Un flux qui se répète rejouerait les dernières secondes d'un segment sans fin : l'enveloppe repart du début du morceau. Et une piste relancée qu'on quitte retrouve son marqueur sans décalage, sans quoi y revenir — piste suivante, file rejouée — la ferait repartir en plein milieu. Les deux se déduisent d'un événement du lecteur enveloppé, non d'un état. La garde posée pendant le remplacement est retirée : aucun test ne tombait sans elle, et pour cause — Media3 livre `onEvents` par un message posté, donc après le rattachement des identifiants. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../playback/TranscodeSeekingPlayer.kt | 111 +++++++++++++----- .../playback/TranscodeSeekingPlayerTest.kt | 74 ++++++++++++ docs/deplacement-dans-un-transcodage.md | 2 + 3 files changed, 158 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt index d8a3772..952c605 100644 --- a/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt +++ b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt @@ -35,23 +35,35 @@ internal class TranscodeSeekingPlayer(player: Player) : ForwardingSimpleBasePlay /** Le décalage du flux de chaque fenêtre relancée ; absente, elle part du début. */ private val decalages = HashMap() - /** - * Vrai le temps d'une relance. - * - * L'ExoPlayer publie ses événements pendant le remplacement même, avec un - * identifiant de piste que l'enveloppe ne sait pas encore rattacher à - * l'ancien : un état lu à cet instant annoncerait un changement de piste. - * On rend donc le dernier état cohérent jusqu'à ce que le lien soit posé. - */ - private var enRelance = false - private var dernierEtat: State? = null - /** La cible d'une relance, à annoncer comme un saut à la place de la discontinuité qu'elle provoque. */ private var sautAnnonce: Long? = null - override fun getState(): State { - dernierEtat?.takeIf { enRelance }?.let { return it } + /** 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) @@ -63,7 +75,7 @@ internal class TranscodeSeekingPlayer(player: Player) : ForwardingSimpleBasePlay ) val builder = etat.buildUpon().setPlaylist(logique, etat.currentTracks, etat.currentMetadata) if (!brute.isEmpty) corrigerLaPisteCourante(etat, logique, builder) - return builder.build().also { dernierEtat = it } + return builder.build() } private fun corrigerLaPisteCourante(etat: State, logique: Timeline, builder: State.Builder) { @@ -139,29 +151,70 @@ internal class TranscodeSeekingPlayer(player: Player) : ForwardingSimpleBasePlay } 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 - enRelance = true - try { - 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 - } finally { - enRelance = false + 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 fenetre = player.currentTimeline.getWindow(courant, Timeline.Window()) + if ((decalages[fenetre.uid] ?: 0L) > 0L) { + relancer(courant, fenetre, 0L) + change = true + } } - if (index == player.currentMediaItemIndex) { - // R6 : annoncé tout de suite, le curseur ne revient pas en arrière. - sautAnnonce = decalageMs - } else { - player.seekTo(index, 0L) + 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. */ diff --git a/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt index 45ac386..747bc17 100644 --- a/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt +++ b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt @@ -285,6 +285,55 @@ class TranscodeSeekingPlayerTest { 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 `une position inconnue reste inconnue`() { // R2 : lui ajouter le décalage publierait un instant plausible et faux. @@ -347,6 +396,23 @@ private class FauxLecteur : SimpleBasePlayer(Looper.getMainLooper()) { 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 { @@ -362,11 +428,19 @@ private class FauxLecteur : SimpleBasePlayer(Looper.getMainLooper()) { .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( diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md index 8e93616..768df26 100644 --- a/docs/deplacement-dans-un-transcodage.md +++ b/docs/deplacement-dans-un-transcodage.md @@ -104,6 +104,8 @@ Deux raisons de l'écrire : `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 From f2374ebe5016117150e3327521026f0fd7af5ccc Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 12 Sep 2026 00:09:46 +0200 Subject: [PATCH 08/10] fix(lecture): rejouer la piste courante depuis la file repart du morceau MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois retours de revue. `seekToDefaultPosition` n'était pas réécrit : c'est pourtant ce qu'appelle un appui sur la ligne en cours dans la file. L'ExoPlayer revenait au début du flux — le milieu du morceau — en gardant le décalage. La surveillance des segments lisait la fenêtre courante sans borner l'index, comme le fait déjà `allerA` : la file peut s'être vidée entre la discontinuité et la salve d'événements. Garde posée et documentée comme non éprouvée. Et la note s'attribuait une couverture de bout en bout pour R5, que le test ne prouve pas : il prouve R1. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../playback/TranscodeSeekingPlayer.kt | 22 +++++++++++++++---- .../playback/TranscodeSeekingPlayerTest.kt | 16 ++++++++++++++ docs/deplacement-dans-un-transcodage.md | 8 +++---- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt index 952c605..0146d78 100644 --- a/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt +++ b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt @@ -121,6 +121,12 @@ internal class TranscodeSeekingPlayer(player: Player) : ForwardingSimpleBasePlay COMMAND_SEEK_TO_MEDIA_ITEM -> allerA(mediaItemIndex, positionMs) + // Le début du morceau est un décalage `0`, pas le début du flux. + // C'est ce que demande un appui sur la ligne en cours de la file : + // `seekToDefaultPosition`, que l'ExoPlayer ramènerait au début du + // segment. + 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. @@ -197,10 +203,18 @@ internal class TranscodeSeekingPlayer(player: Player) : ForwardingSimpleBasePlay if (segmentRepete) { segmentRepete = false val courant = player.currentMediaItemIndex - val fenetre = player.currentTimeline.getWindow(courant, Timeline.Window()) - if ((decalages[fenetre.uid] ?: 0L) > 0L) { - relancer(courant, fenetre, 0L) - change = true + 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 + } } } diff --git a/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt index 747bc17..3601558 100644 --- a/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt +++ b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt @@ -334,6 +334,22 @@ class TranscodeSeekingPlayerTest { assertEquals(0L, faux.dernierDecalage) } + @Test + fun `rejouer la piste courante depuis la file repart du debut du morceau`() { + // `seekToDefaultPosition` : ce que fait un appui sur la ligne en cours + // dans la file. Laissé à l'ExoPlayer, il revient au début du flux — le + // milieu du morceau — en gardant le décalage. + jouer(transcode()) + lecteur.seekTo(133_000L) + ecouler() + + lecteur.seekToDefaultPosition(0) + 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. diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md index 768df26..9b56e6f 100644 --- a/docs/deplacement-dans-un-transcodage.md +++ b/docs/deplacement-dans-un-transcodage.md @@ -186,10 +186,10 @@ saut de l'enveloppe, et non par une position de départ posée sur l'ExoPlayer. 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 à R14 s'y vérifient une à une. R1 et R5 se vérifient -en plus sur la vraie chaîne service + `MediaController` (voir -`PlaybackServiceQualityTest`) : c'est la session qui doit voir la position -logique, pas seulement l'enveloppe. R10 et R11 s'éprouvent sur la chaîne de +fin de flux. Les règles R2 à R14 s'y vérifient une à une. **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 From 00eb20dc9893d8d27b819cc13119834f799d4200 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 12 Sep 2026 00:12:57 +0200 Subject: [PATCH 09/10] =?UTF-8?q?fix(lecture):=20viser=20la=20bonne=20comm?= =?UTF-8?q?ande=20pour=20revenir=20au=20d=C3=A9but?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rectification du commit précédent, qui visait large et se trompait de chemin. `seekToDefaultPosition(index)` — celui de la file — passe par COMMAND_SEEK_TO_MEDIA_ITEM, déjà réécrite : ce chemin n'était pas en défaut. C'est la variante sans rang, qu'un contrôleur envoie pour revenir au début, qui tombait dans le cas par défaut et ramenait au début du flux, c'est-à-dire au milieu du morceau. Le test visait la mauvaise variante : le retrait de la réécriture ne le faisait pas tomber. Il vise désormais celle qui compte. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- .../app/waveflow/playback/TranscodeSeekingPlayer.kt | 9 +++++---- .../waveflow/playback/TranscodeSeekingPlayerTest.kt | 13 ++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt index 0146d78..522c1f1 100644 --- a/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt +++ b/app/src/main/java/app/waveflow/playback/TranscodeSeekingPlayer.kt @@ -121,10 +121,11 @@ internal class TranscodeSeekingPlayer(player: Player) : ForwardingSimpleBasePlay COMMAND_SEEK_TO_MEDIA_ITEM -> allerA(mediaItemIndex, positionMs) - // Le début du morceau est un décalage `0`, pas le début du flux. - // C'est ce que demande un appui sur la ligne en cours de la file : - // `seekToDefaultPosition`, que l'ExoPlayer ramènerait au début du - // segment. + // `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 diff --git a/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt index 3601558..cfb7870 100644 --- a/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt +++ b/app/src/test/java/app/waveflow/playback/TranscodeSeekingPlayerTest.kt @@ -335,15 +335,18 @@ class TranscodeSeekingPlayerTest { } @Test - fun `rejouer la piste courante depuis la file repart du debut du morceau`() { - // `seekToDefaultPosition` : ce que fait un appui sur la ligne en cours - // dans la file. Laissé à l'ExoPlayer, il revient au début du flux — le - // milieu du morceau — en gardant le décalage. + 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(0) + lecteur.seekToDefaultPosition() ecouler() assertEquals(0L, faux.dernierDecalage) From 307446ac250d9b015a44097704cf0181c051f505 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 12 Sep 2026 00:18:19 +0200 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20ne=20plus=20annoncer=20R9=20comme?= =?UTF-8?q?=20v=C3=A9rifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La note disait « R2 à R14 s'y vérifient une à une », alors qu'elle range déjà R9 — un saut chasse le précédent — dans ce qui n'est pas couvert. Rien ne garantit aujourd'hui cette coalescence : remplacer la piste fait abandonner le chargement en cours à l'ExoPlayer, mais ce n'est ni écrit ni éprouvé. Elle viendra avec le 429, où elle a son sens. Claude-Session: https://claude.ai/code/session_01GH1rcBbYQX41tkDEtKfATL --- docs/deplacement-dans-un-transcodage.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/deplacement-dans-un-transcodage.md b/docs/deplacement-dans-un-transcodage.md index 9b56e6f..f8b2b8c 100644 --- a/docs/deplacement-dans-un-transcodage.md +++ b/docs/deplacement-dans-un-transcodage.md @@ -186,7 +186,10 @@ saut de l'enveloppe, et non par une position de départ posée sur l'ExoPlayer. 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 à R14 s'y vérifient une à une. **R1 se vérifie en plus sur la +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