-
Notifications
You must be signed in to change notification settings - Fork 0
feat(lecteur): faire tourner la lecture entre deux points #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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>(AbLoopState.Off) | ||
| val state: StateFlow<AbLoopState> = _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 | ||
| } |
140 changes: 140 additions & 0 deletions
140
app/src/main/java/app/waveflow/playback/AbLoopRunner.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Boolean>, | ||
| 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.