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..f88eb87 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/AbLoopRunner.kt @@ -0,0 +1,140 @@ +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 + +/** + * 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, 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. + */ +internal class AbLoopRunner( + private val scope: CoroutineScope, + private val loop: AbLoop, + private val isPlaying: StateFlow, + private val positionMs: () -> Long, + private val seekTo: (Long) -> Unit, +) { + + /** + * Surveille tant que [scope] vit. + * + * `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 { + 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 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 : + * 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 (!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 + // reboucler une seconde fois. + delay(PAS_MIN) + continue + } + + delay((armee.endMs - position).coerceIn(PAS_MIN, PAS_MAX)) + + 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. + * + * 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..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 @@ -110,6 +111,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 +158,48 @@ 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) { + // 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() + + 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) + } + + 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 + } + }, + ) + } + /** * 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..0adafc7 --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/AbLoopRunnerTest.kt @@ -0,0 +1,225 @@ +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 +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 + + /** 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. + * + * 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, + isPlaying = enLecture, + positionMs = { + lectures++ + 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 `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é : + // 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() + } }