From 316dccea77aec0f1effa5a717e5a7ec2c581c7f6 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 10 Sep 2026 16:49:32 +0200 Subject: [PATCH 1/2] feat(lecteur): faire tourner la lecture entre deux points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media3 n'a pas de « répéter entre deux points » : REPEAT_MODE_ONE reprend la piste entière. Il faut donc échantillonner la position et rembobiner soi-même, ce qui place le mécanisme du côté du lecteur — le service — et non de l'interface, qui n'est pas là quand l'écran est éteint. On pose une boucle pour repiquer un passage, puis on prend son instrument. AbLoop porte les deux bornes sans connaître le lecteur, comme SleepTimer : elle dit où, pas quoi faire. Trois états plutôt que trois champs nullables, si bien qu'un B sans A ne peut pas s'écrire. Les bornes appartiennent à une piste et s'effacent en changeant de morceau — deux instants pris dans un solo ne veulent rien dire dans le suivant. AbLoopRunner reçoit la position et le rembobinage au lieu de les prendre sur un Player : c'est ce qui rend la boucle éprouvable sur la JVM. Sous Robolectric, faute de codec, la position n'avance pas — un test qui passerait par un vrai lecteur ne prouverait que sa propre immobilité. Le sommeil se règle sur ce qui reste avant B, borné des deux côtés. Un pas fixe obligerait à choisir entre un dépassement audible et un réveil toutes les cinquante millisecondes pendant des minutes. Le bouton vit entre les deux durées et non dans l'en-tête : A et B sont des positions, et se posent en regardant celle qui défile. La passation prévoyait un menu de débordement, devenu inutile — l'en-tête ne bouge pas. La position est demandée au lecteur et non prise dans l'état affiché, échantillonné toutes les demi-secondes : un quart de seconde d'erreur moyenne s'entend sur un passage qu'on repique. Claude-Session: https://claude.ai/code/session_01YXSdDq15CsKFvy1WXWGK6i --- .../main/java/app/waveflow/MainActivity.kt | 1 + app/src/main/java/app/waveflow/WaveFlowApp.kt | 11 ++ .../main/java/app/waveflow/playback/AbLoop.kt | 125 +++++++++++++ .../app/waveflow/playback/AbLoopRunner.kt | 117 ++++++++++++ .../playback/Media3PlaybackController.kt | 3 + .../waveflow/playback/PlaybackController.kt | 10 ++ .../app/waveflow/playback/PlaybackService.kt | 31 ++++ .../waveflow/ui/player/NowPlayingScreen.kt | 57 ++++++ .../app/waveflow/ui/player/PlayerUiState.kt | 6 + .../app/waveflow/ui/player/PlayerViewModel.kt | 25 ++- .../app/waveflow/playback/AbLoopRunnerTest.kt | 167 ++++++++++++++++++ .../java/app/waveflow/playback/AbLoopTest.kt | 116 ++++++++++++ .../test/java/app/waveflow/testing/Fakes.kt | 5 + .../waveflow/ui/player/PlayerViewModelTest.kt | 85 +++++++++ 14 files changed, 758 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/waveflow/playback/AbLoop.kt create mode 100644 app/src/main/java/app/waveflow/playback/AbLoopRunner.kt create mode 100644 app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt create mode 100644 app/src/test/java/app/waveflow/playback/AbLoopTest.kt diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 3b580ef..2e6e698 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -654,6 +654,7 @@ private fun WaveFlowRoot( onCancelSleepTimer = playerViewModel::cancelSleepTimer, onSleepTimerRemainingMs = playerViewModel::sleepTimerRemainingMs, onSetPlaybackSpeed = playerViewModel::setPlaybackSpeed, + onMarkAbLoop = playerViewModel::markAbLoop, ) } } diff --git a/app/src/main/java/app/waveflow/WaveFlowApp.kt b/app/src/main/java/app/waveflow/WaveFlowApp.kt index 800a5be..2b93324 100644 --- a/app/src/main/java/app/waveflow/WaveFlowApp.kt +++ b/app/src/main/java/app/waveflow/WaveFlowApp.kt @@ -20,6 +20,7 @@ import app.waveflow.data.remote.HttpServerApi import app.waveflow.data.remote.ServerHttp import app.waveflow.data.remote.ServerImageAuthInterceptor import app.waveflow.data.remote.ServerSessionRepository +import app.waveflow.playback.AbLoop import app.waveflow.playback.Media3PlaybackController import app.waveflow.playback.PlaybackController import app.waveflow.playback.RemoteMediaCache @@ -108,6 +109,16 @@ class AppContainer(app: Application) { */ val sleepTimer = SleepTimer(applicationScope, SystemClock::elapsedRealtime) + /** + * Les bornes de la boucle A-B, portées par l'application comme la minuterie. + * + * On pose une boucle pour repiquer un passage, puis on éteint l'écran et on + * prend son instrument : elle doit survivre à l'écran. Le service + * échantillonne la position et rembobine ; elle-même ne connaît pas le + * lecteur. + */ + val abLoop = AbLoop() + /** * Cache des pistes distantes, unique pour le processus. * diff --git a/app/src/main/java/app/waveflow/playback/AbLoop.kt b/app/src/main/java/app/waveflow/playback/AbLoop.kt new file mode 100644 index 0000000..c5b58a2 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/AbLoop.kt @@ -0,0 +1,125 @@ +package app.waveflow.playback + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Les deux bornes entre lesquelles la lecture doit tourner. + * + * Comme [SleepTimer], elle ne connaît pas le lecteur : elle dit **où**, pas quoi + * faire. Le service échantillonne la position et rembobine ; l'écran lit + * [state] pour dire où en est la pose. La question « entre quels instants + * tourne-t-on » s'éprouve ainsi sans démarrer ni service ni lecteur. + * + * Portée par l'application et non par le service : on pose une boucle pour + * repiquer un passage, puis on éteint l'écran et on prend son instrument. Elle + * doit survivre à l'écran, pas au processus — la lecture s'arrête avec lui de + * toute façon. + * + * **Elle appartient à une piste.** A et B désignent des instants d'un morceau + * donné ; passer au suivant les vide de leur sens, et la boucle s'efface. Sans + * cela, deux bornes prises dans un solo de guitare s'appliqueraient au morceau + * d'après. + */ +class AbLoop { + + private val _state = MutableStateFlow(AbLoopState.Off) + val state: StateFlow = _state.asStateFlow() + + /** + * Pose la borne suivante : d'abord A, puis B, puis efface. + * + * Un seul geste pour les trois temps — c'est ce qu'on attend d'un bouton + * qu'on presse en écoutant, sans quitter la musique des yeux. + * + * @param mediaId la piste sur laquelle on pose. Marquer sur une autre que + * celle où A a été posé recommence : les deux bornes doivent appartenir au + * même morceau, et l'utilisateur qui marque ailleurs désigne un nouveau + * passage, il n'achève pas l'ancien. + * @param positionMs l'instant marqué. + */ + fun mark(mediaId: String, positionMs: Long) { + val instant = positionMs.coerceAtLeast(0L) + + _state.update { courant -> + when { + courant is AbLoopState.Started && courant.mediaId == mediaId -> + courant.armerVers(instant) + + // Armée, un troisième appui efface. Sur une autre piste, il + // recommence — voir plus haut. + courant is AbLoopState.Armed && courant.mediaId == mediaId -> AbLoopState.Off + + else -> AbLoopState.Started(mediaId, instant) + } + } + } + + /** + * Achève la pose, ou la recommence si les deux bornes ne délimitent rien. + * + * Marquer B **avant** A n'est pas une faute : on écoute, on marque, on + * revient en arrière et on marque de nouveau. Les deux instants sont alors + * remis dans l'ordre plutôt que refusés — l'utilisateur a désigné un + * intervalle, son sens de parcours n'est pas son propos. + * + * Deux fois le même instant, en revanche, ne délimite rien : la lecture y + * rebondirait sans avancer. Ce second appui rouvre donc la pose au lieu de + * l'achever. + */ + private fun AbLoopState.Started.armerVers(instant: Long): AbLoopState = when { + instant > startMs -> AbLoopState.Armed(mediaId, startMs, instant) + instant < startMs -> AbLoopState.Armed(mediaId, instant, startMs) + else -> AbLoopState.Started(mediaId, instant) + } + + /** Efface la boucle sans toucher à la lecture en cours. */ + fun clear() { + _state.value = AbLoopState.Off + } + + /** + * Efface la boucle si elle n'appartient pas à [mediaId]. + * + * Appelé au changement de piste. `null` — plus rien ne joue — efface aussi : + * une boucle sans morceau ne désigne rien. + */ + fun clearIfOtherTrack(mediaId: String?) { + _state.update { courant -> + if (courant is AbLoopState.Off || courant.mediaId == mediaId) courant else AbLoopState.Off + } + } +} + +/** + * Où en est la pose des bornes. + * + * Trois états et non trois champs nullables : « A posé sans B » et « rien » se + * distinguent alors d'eux-mêmes, et aucune combinaison absurde — un B sans A — + * ne peut s'écrire. + */ +sealed interface AbLoopState { + + /** La piste à laquelle les bornes appartiennent, `null` quand il n'y en a pas. */ + val mediaId: String? + + /** Aucune borne posée. */ + data object Off : AbLoopState { + override val mediaId: String? = null + } + + /** A est posé, on attend B. */ + data class Started( + override val mediaId: String, + val startMs: Long, + ) : AbLoopState + + /** Les deux bornes sont posées : la lecture doit tourner entre elles. */ + data class Armed( + override val mediaId: String, + val startMs: Long, + val endMs: Long, + ) : AbLoopState +} diff --git a/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt b/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt new file mode 100644 index 0000000..4814787 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt @@ -0,0 +1,117 @@ +package app.waveflow.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Ramène la lecture en A chaque fois qu'elle dépasse B. + * + * Media3 **n'a pas** de « répéter entre deux points » : `REPEAT_MODE_ONE` reprend + * la piste entière. Il faut donc échantillonner la position et rembobiner + * soi-même, ce qui place le mécanisme du côté du lecteur — le service — et non + * de l'interface, qui n'est pas là quand l'écran est éteint. + * + * **Le pas d'échantillonnage se règle sur ce qui reste à parcourir.** Un pas + * fixe obligerait à choisir entre un dépassement audible et un réveil toutes les + * quelques dizaines de millisecondes pendant des minutes. Ici on dort le temps + * qui reste avant B, borné des deux côtés : loin de la borne le réveil est rare, + * près d'elle il se resserre. [PAS_MAX] garde la main sur les sauts de position + * — un `seek` de l'utilisateur, ou une piste qui n'avance pas comme prévu —, et + * [PAS_MIN] empêche de tourner à vide quand la position stagne. + * + * Le dépassement résiduel tient donc à [PAS_MIN] : quelques dizaines de + * millisecondes, en deçà de ce qu'on distingue d'un rembobinage instantané. + * + * La position et le rembobinage sont **reçus** et non pris sur un `Player` : + * c'est ce qui rend la boucle éprouvable sur la JVM. Sous Robolectric, faute de + * codec, la position n'avance pas et `isPlaying` reste faux — un test qui + * passerait par un vrai lecteur ne prouverait rien. + * + * @param positionMs la position de lecture, lue à l'instant de la demande. + * @param seekTo rembobine. Appelé sur le fil d'où tourne [scope], que Media3 + * exige principal. + */ +internal class AbLoopRunner( + private val scope: CoroutineScope, + private val loop: AbLoop, + private val positionMs: () -> Long, + private val seekTo: (Long) -> Unit, +) { + + /** + * Surveille tant que [scope] vit. + * + * `collectLatest` : une boucle remplacée — bornes refaites, piste changée — + * n'a plus à être surveillée, et sa coroutine tombe avec l'état qui l'a + * lancée. + */ + fun start() { + scope.launch { + loop.state.collectLatest { etat -> + if (etat is AbLoopState.Armed) surveiller(etat) + } + } + } + + /** + * Rembobine tant que [armee] est la boucle en vigueur. + * + * L'état est **relu après chaque sommeil**, et pas seulement à l'entrée. + * C'est la garde contre le défaut que ce dépôt a vu revenir trois fois : + * entre le réveil du `delay` et les lignes qui suivent, la coroutine est + * déjà repartie, et l'annulation de `collectLatest` ne la rattrape plus. + * Sans cette relecture, une boucle effacée à cet instant précis rembobinerait + * une dernière fois — au milieu d'un morceau que l'utilisateur venait de + * libérer. + * + * **Cette garde est posée et non éprouvée.** La course qu'elle ferme ne se + * reproduit pas sous `runTest`, dont l'ordonnanceur est mono-fil : c'est + * `collectLatest` qui y annule la surveillance avant tout réveil, si bien + * qu'un test écrit pour elle passe aussi une fois la garde retirée. Même + * conclusion que pour [SleepTimer], et même refus d'un test qui laisserait + * croire la zone couverte. + */ + private suspend fun surveiller(armee: AbLoopState.Armed) { + while (scope.isActive) { + val position = positionMs() + + if (position >= armee.endMs) { + if (loop.state.value != armee) return + seekTo(armee.startMs) + // Repartir de A plutôt que de relire : la position mettrait un + // instant à refléter le rembobinage, et ce délai ferait + // reboucler une seconde fois. + delay(PAS_MIN) + continue + } + + delay((armee.endMs - position).coerceIn(PAS_MIN, PAS_MAX)) + + if (loop.state.value != armee) return + } + } + + private companion object { + /** + * Le dépassement qu'on s'autorise au plus près de B. + * + * Cinquante millisecondes : sous le seuil où l'oreille distingue le + * rembobinage d'une coupure nette, et assez pour ne pas réveiller le + * processeur en pure perte. + */ + const val PAS_MIN = 50L + + /** + * Le sommeil le plus long, même si B est loin. + * + * La position peut sauter sans prévenir — l'utilisateur déplace le + * curseur, la piste bute sur son tampon. Se rendormir jusqu'à B sur la + * foi d'une position d'il y a une minute laisserait la boucle muette + * tout ce temps. + */ + const val PAS_MAX = 500L + } +} diff --git a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt index c46b001..1b2553a 100644 --- a/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/Media3PlaybackController.kt @@ -150,6 +150,9 @@ class Media3PlaybackController( controller?.seekTo(positionMs.coerceAtLeast(0L)) } + override fun currentPositionMs(): Long = + controller?.currentPosition?.coerceAtLeast(0L) ?: 0L + override fun toggleShuffle() { val ctrl = controller ?: return ctrl.shuffleModeEnabled = !ctrl.shuffleModeEnabled diff --git a/app/src/main/java/app/waveflow/playback/PlaybackController.kt b/app/src/main/java/app/waveflow/playback/PlaybackController.kt index f9dd518..92a1a25 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackController.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackController.kt @@ -112,6 +112,16 @@ interface PlaybackController { fun seekTo(positionMs: Long) + /** + * La position de lecture, lue à l'instant de la demande. + * + * [PlaybackState.positionMs] ne suffit pas pour poser une borne de boucle : + * il est échantillonné toutes les demi-secondes, ce qui ajouterait au temps + * de réaction de l'utilisateur un quart de seconde d'erreur moyenne. Sur un + * passage qu'on repique à l'instrument, cela s'entend. + */ + fun currentPositionMs(): Long + fun toggleShuffle() /** Fait tourner le mode de répétition : Off -> All -> One -> Off. */ diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index 9d80449..c1c8376 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -110,6 +110,7 @@ class PlaybackService : MediaLibraryService() { player.addListener(historyListener(container.playHistoryRepository)) observeSleepTimer(container.sleepTimer, player) observePlaybackSpeed(container.preferencesStore, player) + observeAbLoop(container.abLoop, player) // Après la session, et pas avant : la première valeur du flux arrive // sans délai, et elle a des abonnés à prévenir. @@ -156,6 +157,36 @@ class PlaybackService : MediaLibraryService() { } } + /** + * Fait tourner la lecture entre les deux bornes, et efface celles-ci quand + * on change de piste. + * + * Le rembobinage vit ici et non dans l'interface : Media3 n'ayant pas de + * « répéter entre deux points », il faut échantillonner la position, ce que + * seul le porteur du lecteur peut faire — et qu'il doit faire écran éteint. + * + * Voir [AbLoopRunner] pour le pas d'échantillonnage, et pourquoi la position + * lui est passée plutôt que prise sur le lecteur. + */ + private fun observeAbLoop(loop: AbLoop, player: Player) { + AbLoopRunner( + scope = artworkScope, + loop = loop, + positionMs = { player.currentPosition }, + seekTo = player::seekTo, + ).start() + + player.addListener( + object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + // A et B désignent des instants d'un morceau donné : passer + // au suivant les vide de leur sens. + loop.clearIfOtherTrack(mediaItem?.mediaId) + } + }, + ) + } + /** * Note ce qu'on écoute, mais pas ce qu'on saute. * diff --git a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt index 69363c7..9679a3a 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,6 +57,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.waveflow.model.PlaybackSpeed import app.waveflow.model.orUnknownArtist +import app.waveflow.playback.AbLoopState import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode import app.waveflow.ui.components.Artwork @@ -85,6 +87,7 @@ fun NowPlayingScreen( onCancelSleepTimer: () -> Unit, onSleepTimerRemainingMs: () -> Long?, onSetPlaybackSpeed: (Float) -> Unit, + onMarkAbLoop: () -> Unit, modifier: Modifier = Modifier, ) { // Local et non remonté : voir la file est une façon de regarder le lecteur, @@ -167,6 +170,8 @@ fun NowPlayingScreen( durationMs = state.durationMs, trackKey = track.mediaId, onSeek = onSeek, + abLoop = state.abLoop, + onMarkAbLoop = onMarkAbLoop, ) Spacer(Modifier.height(8.dp)) @@ -329,6 +334,8 @@ private fun SeekBar( durationMs: Long, trackKey: String, onSeek: (Long) -> Unit, + abLoop: AbLoopState, + onMarkAbLoop: () -> Unit, ) { // Pendant un glissement, la position affichée suit le doigt et non le // lecteur ; remise à zéro dès qu'on change de morceau. @@ -363,6 +370,12 @@ private fun SeekBar( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + // Entre les deux durées, et non dans l'en-tête : A et B sont des + // positions, et se posent en regardant celle qui défile. L'en-tête + // est par ailleurs plein. + AbLoopButton(abLoop = abLoop, onClick = onMarkAbLoop) + Text( text = if (hasDuration) formatDuration(durationMs) else "--:--", style = MaterialTheme.typography.labelMedium, @@ -488,6 +501,50 @@ private fun PlaybackSpeedButton(speed: Float, onClick: () -> Unit) { } } +/** + * La boucle A-B : un seul bouton pour les trois temps. + * + * D'abord A, puis B, puis efface — c'est ce qu'on attend de quelque chose qu'on + * presse en écoutant, sans quitter la musique des yeux. Le libellé dit où en est + * la pose plutôt que ce que fera le prochain appui : « A-… » se lit comme une + * phrase laissée en suspens, ce qu'est précisément une boucle dont B manque. + * + * Une boucle active et muette serait le même défaut que la vitesse invisible — + * on chercherait longtemps pourquoi le morceau se répète. D'où la teinte, et une + * description qui donne les deux bornes. + */ +@Composable +private fun AbLoopButton(abLoop: AbLoopState, onClick: () -> Unit) { + val libelle = when (abLoop) { + is AbLoopState.Started -> "A-…" + else -> "A-B" + } + + val description = when (abLoop) { + AbLoopState.Off -> "Boucle A-B : poser le début" + is AbLoopState.Started -> "Boucle A-B : poser la fin" + is AbLoopState.Armed -> + "Boucle active de ${formatDuration(abLoop.startMs)} " + + "à ${formatDuration(abLoop.endMs)} — appuyer pour l'effacer" + } + + TextButton( + onClick = onClick, + modifier = Modifier.semantics { contentDescription = description }, + ) { + Text( + text = libelle, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + color = if (abLoop == AbLoopState.Off) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + ) + } +} + /** Bouton secondaire dont la teinte signale l'état actif. */ @Composable private fun ToggleControl( diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt index 93c1b21..17f186d 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -1,6 +1,7 @@ package app.waveflow.ui.player import app.waveflow.model.PlaybackSpeed +import app.waveflow.playback.AbLoopState import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode @@ -19,6 +20,10 @@ import app.waveflow.playback.RepeatMode * non du lecteur : le flux des préférences émet au moment du choix, quand * celui du lecteur, lui, ne se rafraîchit qu'aux tics de position — donc plus * du tout en pause, où l'on règle pourtant volontiers sa vitesse. + * @property abLoop où en est la pose des bornes de boucle. Elle vient de son + * propre flux, pour la même raison que la vitesse : on pose une borne, on + * met en pause pour reprendre son instrument, et l'état du lecteur cesse + * alors d'émettre. */ data class PlayerUiState( val track: PlayingTrack? = null, @@ -32,6 +37,7 @@ data class PlayerUiState( val queueIndex: Int = -1, val sleepTimerActive: Boolean = false, val playbackSpeed: Float = PlaybackSpeed.NORMALE, + val abLoop: AbLoopState = AbLoopState.Off, ) { /** Ce qui reste à jouer après le morceau courant. */ diff --git a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt index e8c0f99..7549401 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -9,6 +9,7 @@ import app.waveflow.WaveFlowApp import app.waveflow.data.PreferencesStore import app.waveflow.model.RemoteSong import app.waveflow.model.Song +import app.waveflow.playback.AbLoop import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackFailure import app.waveflow.playback.SleepTimer @@ -33,6 +34,7 @@ class PlayerViewModel( private val playbackController: PlaybackController, private val sleepTimer: SleepTimer, private val preferencesStore: PreferencesStore, + private val abLoop: AbLoop, ) : ViewModel() { // Plus de croisement avec la bibliothèque : le lecteur décrit lui-même sa @@ -53,7 +55,8 @@ class PlayerViewModel( playbackController.state, sleepTimer.endsAtMs, preferencesStore.preferences.map { it.playbackSpeed }.distinctUntilChanged(), - ) { playback, endsAt, speed -> + abLoop.state, + ) { playback, endsAt, speed, boucle -> PlayerUiState( track = playback.current, isPlaying = playback.isPlaying, @@ -66,6 +69,7 @@ class PlayerViewModel( queueIndex = playback.queueIndex, sleepTimerActive = endsAt != null, playbackSpeed = speed, + abLoop = boucle, ) }.stateIn( scope = viewModelScope, @@ -177,6 +181,24 @@ class PlayerViewModel( viewModelScope.launch { preferencesStore.setPlaybackSpeed(speed) } } + /** + * Pose la borne suivante de la boucle : A, puis B, puis efface. + * + * La position est demandée au lecteur et non prise dans l'état affiché : + * celui-ci est échantillonné toutes les demi-secondes, et une borne posée + * un quart de seconde trop tôt s'entend sur un passage qu'on repique. + * + * Sans piste courante il n'y a rien à borner : les bornes appartiennent à + * un morceau. + */ + fun markAbLoop() { + val mediaId = playbackController.state.value.current?.mediaId ?: return + abLoop.mark(mediaId, playbackController.currentPositionMs()) + } + + /** Efface la boucle sans toucher à la lecture en cours. */ + fun clearAbLoop() = abLoop.clear() + /** * Ce qu'il reste avant l'arrêt automatique, lu à l'instant de la demande. * @@ -202,6 +224,7 @@ class PlayerViewModel( playbackController = app.container.createPlaybackController(), sleepTimer = app.container.sleepTimer, preferencesStore = app.container.preferencesStore, + abLoop = app.container.abLoop, ) } } diff --git a/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt b/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt new file mode 100644 index 0000000..4e43912 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt @@ -0,0 +1,167 @@ +package app.waveflow.playback + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Le rembobinage, éprouvé sans lecteur. + * + * La position et le `seek` sont reçus par [AbLoopRunner] plutôt que pris sur un + * `Player` : c'est ce qui rend la boucle mesurable ici. Sous Robolectric, faute + * de codec, la position n'avance pas — un test qui passerait par un vrai lecteur + * ne prouverait que sa propre immobilité. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AbLoopRunnerTest { + + private val loop = AbLoop() + + /** Les rembobinages demandés, dans l'ordre. */ + private val seeks = mutableListOf() + + /** La position de lecture, que le test avance à la main. */ + private var position = 0L + + /** + * Le rembobinage **déplace la position**, comme le ferait le lecteur. + * + * Ce n'est pas un détail de confort : un faux qui note le `seek` sans + * bouger laisserait la position au-delà de B, et la surveillance + * rembobinerait sans fin. Le test compterait alors des tours que le vrai + * lecteur ne fait pas. + */ + private fun TestScope.runner() = AbLoopRunner( + scope = backgroundScope, + loop = loop, + positionMs = { position }, + seekTo = { + seeks += it + position = it + }, + ).also { it.start() } + + private fun armer(startMs: Long, endMs: Long) { + loop.mark(PISTE, startMs) + loop.mark(PISTE, endMs) + } + + @Test + fun `depasser B ramene en A`() = runTest { + runner() + armer(10_000L, 25_000L) + runCurrent() + + position = 25_000L + advanceTimeBy(600L) + + assertEquals(listOf(10_000L), seeks) + } + + @Test + fun `tant qu'on est avant B rien ne bouge`() = runTest { + runner() + armer(10_000L, 25_000L) + runCurrent() + + position = 24_000L + advanceTimeBy(5_000L) + + assertTrue("rembobiner avant B couperait le passage en cours", seeks.isEmpty()) + } + + @Test + fun `la boucle tourne plusieurs fois`() = runTest { + // Un seul rembobinage suffirait à un test qui ne regarde que le premier + // tour ; ce qu'on veut est qu'elle *tourne*. + runner() + armer(10_000L, 25_000L) + runCurrent() + + // Chaque tour : la lecture court jusqu'à B, on la laisse rembobiner. + // Le faux `seekTo` la ramène en A de lui-même, comme le lecteur. + repeat(3) { + position = 25_000L + advanceTimeBy(600L) + } + + assertEquals(listOf(10_000L, 10_000L, 10_000L), seeks) + } + + @Test + fun `effacer la boucle arrete le rembobinage`() = runTest { + runner() + armer(10_000L, 25_000L) + runCurrent() + + loop.clear() + runCurrent() + position = 30_000L + advanceTimeBy(5_000L) + + assertTrue("une boucle effacée ne doit plus retenir la lecture", seeks.isEmpty()) + } + + @Test + fun `changer de piste arrete le rembobinage`() = runTest { + runner() + armer(10_000L, 25_000L) + runCurrent() + + loop.clearIfOtherTrack(AUTRE_PISTE) + runCurrent() + position = 30_000L + advanceTimeBy(5_000L) + + assertTrue(seeks.isEmpty()) + } + + @Test + fun `A pose sans B ne retient pas la lecture`() = runTest { + // La pose est en deux temps : tant que B manque, il n'y a pas + // d'intervalle, et la lecture doit courir jusqu'au bout du morceau. + runner() + loop.mark(PISTE, 10_000L) + runCurrent() + + position = 300_000L + advanceTimeBy(5_000L) + + assertTrue(seeks.isEmpty()) + } + + @Test + fun `sur une longue boucle le rembobinage ne se fait pas attendre`() = runTest { + // Le sommeil se règle sur ce qui reste avant B, mais reste plafonné : + // sans ce plafond, une boucle de dix minutes ferait dormir dix minutes, + // et un saut de position — un `seek` de l'utilisateur — resterait sans + // effet tout ce temps. + runner() + armer(0L, 600_000L) + runCurrent() + + position = 600_000L + advanceTimeBy(600L) + + assertEquals(listOf(0L), seeks) + } + + // Il n'y a pas de test pour la relecture d'état qui suit chaque sommeil + // dans `surveiller`. Un test écrit pour elle passait le retrait de la + // garde : sous `runTest`, l'ordonnanceur est mono-fil, et c'est + // `collectLatest` qui annule la surveillance avant qu'elle ne se réveille. + // La course que la garde ferme — l'annulation qui arrive après que la + // continuation du `delay` a été résumée — ne s'y reproduit pas. Le test a + // été retiré plutôt que de laisser croire la zone couverte ; c'est la même + // conclusion que pour `SleepTimer`. + + private companion object { + const val PISTE = "piste-1" + const val AUTRE_PISTE = "piste-2" + } +} diff --git a/app/src/test/java/app/waveflow/playback/AbLoopTest.kt b/app/src/test/java/app/waveflow/playback/AbLoopTest.kt new file mode 100644 index 0000000..3e86158 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/AbLoopTest.kt @@ -0,0 +1,116 @@ +package app.waveflow.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** La pose des deux bornes, sans lecteur ni service. */ +class AbLoopTest { + + private val loop = AbLoop() + + @Test + fun `le premier appui pose A, le second arme la boucle`() { + loop.mark(PISTE, 10_000L) + + assertEquals(AbLoopState.Started(PISTE, 10_000L), loop.state.value) + + loop.mark(PISTE, 25_000L) + + assertEquals(AbLoopState.Armed(PISTE, 10_000L, 25_000L), loop.state.value) + } + + @Test + fun `le troisieme appui efface`() { + // Un seul bouton pour les trois temps : c'est ce qu'on attend de + // quelque chose qu'on presse en écoutant, sans quitter la musique. + loop.mark(PISTE, 10_000L) + loop.mark(PISTE, 25_000L) + + loop.mark(PISTE, 30_000L) + + assertEquals(AbLoopState.Off, loop.state.value) + } + + @Test + fun `marquer B avant A remet les bornes dans l'ordre`() { + // On écoute, on marque, on revient en arrière et on marque de nouveau : + // l'utilisateur a désigné un intervalle, son sens de parcours n'est pas + // son propos. Refuser le second appui l'obligerait à tout recommencer. + loop.mark(PISTE, 25_000L) + loop.mark(PISTE, 10_000L) + + assertEquals(AbLoopState.Armed(PISTE, 10_000L, 25_000L), loop.state.value) + } + + @Test + fun `deux fois le meme instant ne delimite rien et rouvre la pose`() { + // La lecture y rebondirait sans avancer. Rouvrir la pose vaut mieux que + // d'armer une boucle qui fige le morceau sur place. + loop.mark(PISTE, 10_000L) + loop.mark(PISTE, 10_000L) + + assertEquals(AbLoopState.Started(PISTE, 10_000L), loop.state.value) + } + + @Test + fun `marquer sur une autre piste recommence la pose`() { + // Les deux bornes doivent appartenir au même morceau. Qui marque + // ailleurs désigne un nouveau passage, il n'achève pas l'ancien. + loop.mark(PISTE, 10_000L) + + loop.mark(AUTRE_PISTE, 4_000L) + + assertEquals(AbLoopState.Started(AUTRE_PISTE, 4_000L), loop.state.value) + } + + @Test + fun `changer de piste efface une boucle armee`() { + // A et B désignent des instants d'un morceau donné. Sans cet effacement, + // deux bornes prises dans un solo de guitare s'appliqueraient au morceau + // d'après, qui n'a rien demandé. + loop.mark(PISTE, 10_000L) + loop.mark(PISTE, 25_000L) + + loop.clearIfOtherTrack(AUTRE_PISTE) + + assertEquals(AbLoopState.Off, loop.state.value) + } + + @Test + fun `rester sur la meme piste laisse la boucle intacte`() { + // Media3 rapporte aussi une transition en rebouclant sur le même + // morceau : l'effacer là ferait tomber la boucle au premier tour. + loop.mark(PISTE, 10_000L) + loop.mark(PISTE, 25_000L) + val armee = loop.state.value + + loop.clearIfOtherTrack(PISTE) + + assertEquals(armee, loop.state.value) + } + + @Test + fun `la lecture qui s'arrete efface la boucle`() { + // Une boucle sans morceau ne désigne rien. + loop.mark(PISTE, 10_000L) + + loop.clearIfOtherTrack(null) + + assertEquals(AbLoopState.Off, loop.state.value) + } + + @Test + fun `une position negative est ramenee a zero`() { + // `currentPosition` peut rendre une valeur négative tant que le lecteur + // n'est pas prêt ; une borne avant le début rendrait la boucle + // inatteignable. + loop.mark(PISTE, -500L) + + assertEquals(AbLoopState.Started(PISTE, 0L), loop.state.value) + } + + private companion object { + const val PISTE = "piste-1" + const val AUTRE_PISTE = "piste-2" + } +} diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index 0b09fcf..a5db6f1 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -203,6 +203,11 @@ class FakePlaybackController : PlaybackController { override fun seekTo(positionMs: Long) = Unit + /** La position que le test veut voir lue au moment d'un geste. */ + var currentPosition = 0L + + override fun currentPositionMs(): Long = currentPosition + override fun toggleShuffle() = Unit override fun cycleRepeatMode() = Unit diff --git a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt index e8d4879..d5676ec 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.viewmodel.viewModelFactory import app.waveflow.data.PreferencesStore import app.waveflow.model.AppPreferences import app.waveflow.model.PlaybackSpeed +import app.waveflow.playback.AbLoop +import app.waveflow.playback.AbLoopState import app.waveflow.playback.PlaybackFailure import app.waveflow.playback.PlaybackState import app.waveflow.playback.PlayingTrack @@ -44,6 +46,18 @@ class PlayerViewModelTest { private val remoteSongs = listOf(remoteSong("a"), remoteSong("b"), remoteSong("c")) private val controller = FakePlaybackController() private val preferences = FakePreferencesStore() + private val abLoop = AbLoop() + + /** Une piste chargée dans le lecteur, à laquelle des bornes peuvent tenir. */ + private val piste = PlayingTrack( + mediaId = "remote:a", + title = "Résonance", + artist = "Bruit de Fond", + album = "Écho", + artworkUri = null, + localSongId = null, + source = TrackSource.Remote, + ) /** * Le ViewModel sous test, muni d'une minuterie qui suit l'horloge virtuelle. @@ -58,6 +72,7 @@ class PlayerViewModelTest { playbackController = controller, sleepTimer = SleepTimer(backgroundScope) { testScheduler.currentTime }, preferencesStore = preferencesStore, + abLoop = abLoop, ) @Test @@ -363,4 +378,74 @@ class PlayerViewModelTest { assertEquals(1.75f, preferences.playbackSpeed, 0f) } + + @Test + fun `poser une borne lit la position du lecteur, pas celle de l'etat`() = runTest { + // L'état est échantillonné toutes les demi-secondes : poser A dessus + // ajouterait au temps de réaction un quart de seconde d'erreur moyenne, + // ce qui s'entend sur un passage qu'on repique à l'instrument. + val viewModel = playerViewModel() + controller.emit(PlaybackState(current = piste, positionMs = 10_000L)) + controller.currentPosition = 12_345L + + viewModel.markAbLoop() + + assertEquals(AbLoopState.Started(piste.mediaId, 12_345L), abLoop.state.value) + } + + @Test + fun `sans piste courante il n'y a rien a borner`() = runTest { + // Les bornes appartiennent à un morceau ; sans lui elles ne désignent + // rien, et une boucle posée là suivrait la première piste venue. + val viewModel = playerViewModel() + controller.currentPosition = 12_345L + + viewModel.markAbLoop() + + assertEquals(AbLoopState.Off, abLoop.state.value) + } + + @Test + fun `la boucle apparait dans l'etat alors que le lecteur n'emet plus rien`() = runTest { + // Même piège que la vitesse : on pose une borne, on met en pause pour + // reprendre son instrument, et `PlaybackState` cesse d'émettre. Le + // contrôleur reste ici muet après la pose — si l'état tenait la boucle + // de lui, rien ne bougerait. + val viewModel = playerViewModel() + controller.emit(PlaybackState(current = piste)) + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + controller.currentPosition = 10_000L + viewModel.markAbLoop() + // Deux instants distincts : deux fois le même ne délimiterait rien et + // rouvrirait la pose. + controller.currentPosition = 25_000L + viewModel.markAbLoop() + advanceUntilIdle() + + assertEquals( + AbLoopState.Armed(piste.mediaId, 10_000L, 25_000L), + viewModel.state.value.abLoop, + ) + + job.cancel() + } + + @Test + fun `effacer la boucle l'efface de l'etat`() = runTest { + val viewModel = playerViewModel() + controller.emit(PlaybackState(current = piste)) + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + viewModel.markAbLoop() + advanceUntilIdle() + viewModel.clearAbLoop() + advanceUntilIdle() + + assertEquals(AbLoopState.Off, viewModel.state.value.abLoop) + + job.cancel() + } } From 83236e2ad374109b9e9b3739c9dbc7b4429f0bcd Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 10 Sep 2026 21:25:59 +0200 Subject: [PATCH 2/2] =?UTF-8?q?perf(lecteur):=20ne=20pas=20=C3=A9chantillo?= =?UTF-8?q?nner=20la=20position=20quand=20rien=20ne=20joue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En pause, la position reste éternellement sous B : la surveillance de la boucle A-B se rendormait donc toutes les 500 ms — toutes les 50 ms si l'on avait mis en pause juste avant la borne — pour constater à chaque réveil qu'il ne s'était rien passé. Une pause dure ce que dure une pause ; le coût, lui, ne s'arrêtait pas. L'état de lecture est reçu comme le reste, sous forme de flux, plutôt que pris sur un `Player` : c'est ce qui garde la boucle éprouvable sur la JVM, où la position n'avance pas faute de codec. Le `combine` place ce signal dans le `collectLatest` qui existait déjà, si bien que la surveillance n'est pas mise en attente à la pause — elle est annulée, puis relancée à la reprise. La garde relue après chaque sommeil lit désormais les deux conditions ensemble : une boucle effacée et une lecture arrêtée interdisent l'une comme l'autre de rembobiner, et rien ne dit laquelle est arrivée pendant le sommeil. Le premier test compte les lectures de position plutôt que les rembobinages : « aucun rembobinage » serait vrai d'une boucle qui tourne à vide, c'est-à-dire du défaut lui-même. Le second garde contre la sur-correction — une garde qui ne se rouvrirait jamais passerait le premier. Claude-Session: https://claude.ai/code/session_016QQyaWubSrCNLKtetaezxL --- .../app/waveflow/playback/AbLoopRunner.kt | 49 +++++++++++---- .../app/waveflow/playback/PlaybackService.kt | 13 ++++ .../app/waveflow/playback/AbLoopRunnerTest.kt | 60 ++++++++++++++++++- 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt b/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt index 4814787..f88eb87 100644 --- a/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt +++ b/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt @@ -2,7 +2,9 @@ package app.waveflow.playback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -25,11 +27,17 @@ import kotlinx.coroutines.launch * Le dépassement résiduel tient donc à [PAS_MIN] : quelques dizaines de * millisecondes, en deçà de ce qu'on distingue d'un rembobinage instantané. * - * La position et le rembobinage sont **reçus** et non pris sur un `Player` : - * c'est ce qui rend la boucle éprouvable sur la JVM. Sous Robolectric, faute de - * codec, la position n'avance pas et `isPlaying` reste faux — un test qui - * passerait par un vrai lecteur ne prouverait rien. + * La position, le rembobinage et l'état de lecture sont **reçus** et non pris + * sur un `Player` : c'est ce qui rend la boucle éprouvable sur la JVM. Sous + * Robolectric, faute de codec, la position n'avance pas et `isPlaying` reste + * faux — un test qui passerait par un vrai lecteur ne prouverait rien. * + * @param isPlaying vrai tant que la lecture avance. **Rien n'est échantillonné + * quand elle est fausse** : une position à l'arrêt reste éternellement sous B, + * et la surveillance réveillerait le service toutes les [PAS_MAX] — toutes les + * [PAS_MIN] si l'on a mis en pause juste avant la borne — pour constater + * qu'aucune position n'a bougé. Une pause dure ce que dure une pause : le + * coût, lui, ne s'arrête pas. * @param positionMs la position de lecture, lue à l'instant de la demande. * @param seekTo rembobine. Appelé sur le fil d'où tourne [scope], que Media3 * exige principal. @@ -37,6 +45,7 @@ import kotlinx.coroutines.launch internal class AbLoopRunner( private val scope: CoroutineScope, private val loop: AbLoop, + private val isPlaying: StateFlow, private val positionMs: () -> Long, private val seekTo: (Long) -> Unit, ) { @@ -44,20 +53,23 @@ internal class AbLoopRunner( /** * Surveille tant que [scope] vit. * - * `collectLatest` : une boucle remplacée — bornes refaites, piste changée — - * n'a plus à être surveillée, et sa coroutine tombe avec l'état qui l'a - * lancée. + * `collectLatest` : une boucle remplacée — bornes refaites, piste changée, + * lecture suspendue — n'a plus à être surveillée, et sa coroutine tombe avec + * l'état qui l'a lancée. C'est aussi ce qui arrête l'échantillonnage à la + * pause sans avoir à le demander : la surveillance n'est pas mise en attente, + * elle est annulée, puis relancée à la reprise. */ fun start() { scope.launch { - loop.state.collectLatest { etat -> - if (etat is AbLoopState.Armed) surveiller(etat) - } + combine(loop.state, isPlaying) { etat, joue -> etat.takeIf { joue } } + .collectLatest { etat -> + if (etat is AbLoopState.Armed) surveiller(etat) + } } } /** - * Rembobine tant que [armee] est la boucle en vigueur. + * Rembobine tant que [armee] est la boucle en vigueur et que la lecture avance. * * L'état est **relu après chaque sommeil**, et pas seulement à l'entrée. * C'est la garde contre le défaut que ce dépôt a vu revenir trois fois : @@ -79,7 +91,7 @@ internal class AbLoopRunner( val position = positionMs() if (position >= armee.endMs) { - if (loop.state.value != armee) return + if (!enVigueur(armee)) return seekTo(armee.startMs) // Repartir de A plutôt que de relire : la position mettrait un // instant à refléter le rembobinage, et ce délai ferait @@ -90,10 +102,21 @@ internal class AbLoopRunner( delay((armee.endMs - position).coerceIn(PAS_MIN, PAS_MAX)) - if (loop.state.value != armee) return + if (!enVigueur(armee)) return } } + /** + * [armee] commande-t-elle encore le lecteur ? + * + * Les deux conditions sont lues **ensemble et au dernier moment** : une + * boucle effacée et une lecture mise en pause interdisent l'une comme + * l'autre de rembobiner, et rien ne dit laquelle des deux est arrivée + * pendant le sommeil. + */ + private fun enVigueur(armee: AbLoopState.Armed) = + loop.state.value == armee && isPlaying.value + private companion object { /** * Le dépassement qu'on s'autorise au plus près de B. diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index c1c8376..bce7f6c 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -169,9 +170,15 @@ class PlaybackService : MediaLibraryService() { * lui est passée plutôt que prise sur le lecteur. */ private fun observeAbLoop(loop: AbLoop, player: Player) { + // Le lecteur ne publie pas son état, il le notifie : on le tient donc + // ici, pour le passer au surveillant sous la même forme que le reste — + // reçu, et non pris sur le `Player`. + val enLecture = MutableStateFlow(player.isPlaying) + AbLoopRunner( scope = artworkScope, loop = loop, + isPlaying = enLecture, positionMs = { player.currentPosition }, seekTo = player::seekTo, ).start() @@ -183,6 +190,12 @@ class PlaybackService : MediaLibraryService() { // au suivant les vide de leur sens. loop.clearIfOtherTrack(mediaItem?.mediaId) } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + // Échantillonner une position à l'arrêt réveillerait le + // service sans fin pour constater qu'elle n'a pas bougé. + enLecture.value = isPlaying + } }, ) } diff --git a/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt b/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt index 4e43912..0adafc7 100644 --- a/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt +++ b/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt @@ -1,6 +1,7 @@ package app.waveflow.playback import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent @@ -28,6 +29,18 @@ class AbLoopRunnerTest { /** La position de lecture, que le test avance à la main. */ private var position = 0L + /** La lecture avance, sauf quand un test dit le contraire. */ + private val enLecture = MutableStateFlow(true) + + /** + * Combien de fois la position a été demandée. + * + * C'est la seule trace observable des réveils : à la pause, ce qu'on veut + * n'est pas « aucun rembobinage » — ce serait vrai d'une boucle qui tourne + * à vide — mais **plus aucun échantillon**. + */ + private var lectures = 0 + /** * Le rembobinage **déplace la position**, comme le ferait le lecteur. * @@ -39,7 +52,11 @@ class AbLoopRunnerTest { private fun TestScope.runner() = AbLoopRunner( scope = backgroundScope, loop = loop, - positionMs = { position }, + isPlaying = enLecture, + positionMs = { + lectures++ + position + }, seekTo = { seeks += it position = it @@ -135,6 +152,47 @@ class AbLoopRunnerTest { assertTrue(seeks.isEmpty()) } + @Test + fun `en pause, plus rien n'est echantillonne`() = runTest { + // Une position à l'arrêt reste éternellement sous B. Sans cette garde, + // la surveillance réveillerait le service toutes les 500 ms — et toutes + // les 50 ms si l'on met en pause juste avant la borne — pour constater + // à chaque fois que rien n'a bougé. Une pause dure ce que dure une + // pause ; le coût, lui, ne s'arrêterait pas. + runner() + armer(10_000L, 25_000L) + runCurrent() + position = 24_000L + advanceTimeBy(2_000L) + + enLecture.value = false + runCurrent() + val avantLaPause = lectures + advanceTimeBy(60_000L) + + assertEquals("une minute de pause ne doit coûter aucun réveil", avantLaPause, lectures) + } + + @Test + fun `la reprise remet la boucle en marche`() = runTest { + // Le pendant du test précédent : une garde qui ne se rouvrirait jamais + // arrêterait aussi bien l'échantillonnage, et la boucle avec. + runner() + armer(10_000L, 25_000L) + runCurrent() + + enLecture.value = false + runCurrent() + position = 30_000L + advanceTimeBy(5_000L) + assertTrue("en pause, la lecture n'est pas ramenée en A", seeks.isEmpty()) + + enLecture.value = true + advanceTimeBy(600L) + + assertEquals(listOf(10_000L), seeks) + } + @Test fun `sur une longue boucle le rembobinage ne se fait pas attendre`() = runTest { // Le sommeil se règle sur ce qui reste avant B, mais reste plafonné :