Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ private fun WaveFlowRoot(
onCancelSleepTimer = playerViewModel::cancelSleepTimer,
onSleepTimerRemainingMs = playerViewModel::sleepTimerRemainingMs,
onSetPlaybackSpeed = playerViewModel::setPlaybackSpeed,
onMarkAbLoop = playerViewModel::markAbLoop,
)
}
}
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/app/waveflow/WaveFlowApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
125 changes: 125 additions & 0 deletions app/src/main/java/app/waveflow/playback/AbLoop.kt
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 app/src/main/java/app/waveflow/playback/AbLoopRunner.kt
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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/app/waveflow/playback/PlaybackController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/app/waveflow/playback/PlaybackService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
Loading