From b837a46847ccaacbec33e23b764990a819d76fa5 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 09:43:38 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(lecteur):=20arr=C3=AAter=20la=20lectur?= =?UTF-8?q?e=20d'elle-m=C3=AAme=20au=20bout=20d'un=20moment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La minuterie de veille, premier des trois restes du lot 3. `SleepTimer` ne connaît pas le lecteur : elle dit **quand**, pas quoi faire. Le service écoute ses expirations et met en pause ; l'écran lit son échéance pour allumer son icône. La question « quand faut-il s'arrêter » s'éprouve ainsi sans démarrer de service ni de lecteur — huit tests y répondent. Portée par l'application et non par le service : on règle une minuterie puis on quitte souvent l'application elle-même. Deux points de conception : `endsAtMs` plutôt qu'un décompte. Un `StateFlow` du temps restant demanderait une coroutine qui l'entretient à la seconde, pour un affichage que personne ne regarde la plupart du temps. L'échéance ne bouge pas tant que la minuterie n'est pas retouchée ; qui veut un décompte le dérive — le ViewModel le fait au rythme des tics de position, gratuitement. `expirations` distinct de l'état. Annuler et arriver à échéance vident tous deux `endsAtMs` : sans ce second canal, le service ne saurait pas s'il doit mettre en pause ou s'il vient d'obéir à l'utilisateur. Une pause et non un arrêt : on se rendort rarement pour de bon, et reprendre là où l'on s'est endormi vaut mieux que de retrouver une file vide. Retrait de `job?.cancel()` → trois tests tombent, ceux qui dépendent de l'annulation effective, `cancel()` servant aussi au réarmement. --- Au passage, l'avertissement de compilation apparu avec AGP 9 : `textReport` est déprécié. Le retirer sèchement coûtait la propriété que le commentaire défendait — les remontées du lint n'arrivaient plus au journal, seulement dans un fichier que la CI n'ouvre pas. `textOutput`, le remplacement apparent, est déprécié de la même façon. Le rapport texte est donc lu et réimprimé par une tâche. Une seule, nommée exactement : AGP en crée plusieurs qui commencent par `lint`, et les prendre toutes imprimait le rapport trois fois. Claude-Session: https://claude.ai/code/session_01Fy19suuEYZBqct32VbtQL7 --- app/build.gradle.kts | 32 +++- .../main/java/app/waveflow/MainActivity.kt | 2 + app/src/main/java/app/waveflow/WaveFlowApp.kt | 11 ++ .../app/waveflow/playback/PlaybackService.kt | 19 +++ .../java/app/waveflow/playback/SleepTimer.kt | 98 ++++++++++++ .../waveflow/ui/player/NowPlayingScreen.kt | 42 ++++++ .../app/waveflow/ui/player/PlayerUiState.kt | 10 ++ .../app/waveflow/ui/player/PlayerViewModel.kt | 17 ++- .../app/waveflow/ui/player/SleepTimerSheet.kt | 101 +++++++++++++ .../app/waveflow/playback/SleepTimerTest.kt | 140 ++++++++++++++++++ .../waveflow/ui/player/PlayerViewModelTest.kt | 75 ++++++++-- .../waveflow/ui/player/SleepTimerSheetTest.kt | 30 ++++ 12 files changed, 560 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/app/waveflow/playback/SleepTimer.kt create mode 100644 app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt create mode 100644 app/src/test/java/app/waveflow/playback/SleepTimerTest.kt create mode 100644 app/src/test/java/app/waveflow/ui/player/SleepTimerSheetTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d54119e..0a4fc49 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -60,8 +60,6 @@ android { // `targetSdk` reste volontairement en deçà de `compileSdk` : rien ici // n'opte pour les nouveaux comportements d'exécution (voir plus haut). disable += "OldTargetApi" - // La CI n'ouvre pas le rapport HTML ; le texte, lui, arrive au journal. - textReport = true } testOptions { unitTests { @@ -126,3 +124,33 @@ dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) } + +/* + * Fait remonter les avertissements du lint au journal du build. + * + * AGP 9 produit toujours le rapport texte, mais dans un fichier que la CI + * n'ouvre pas — et les deux propriétés qui l'y amenaient, `textReport` et + * `textOutput`, sont dépréciées ensemble. Les garder coûterait un avertissement + * de compilation à chaque build, alors que le dépôt tient à n'en avoir aucun. + * + * Le fichier est donc lu et réimprimé. Le chemin est résolu à la configuration, + * hors du `doLast`, pour rester compatible avec le cache de configuration. + * + * Une seule tâche, nommée exactement : AGP en crée plusieurs qui commencent par + * `lint` — `lintReportDebug`, `lintAnalyzeDebug` — et les prendre toutes + * imprimait le rapport trois fois. + */ +tasks.matching { it.name == "lintDebug" }.configureEach { + val rapport = layout.buildDirectory.file("reports/lint-results-debug.txt") + doLast { + val fichier = rapport.get().asFile + if (!fichier.exists()) return@doLast + + val texte = fichier.readText().trim() + // « No issues found. » n'apprend rien et noierait le journal d'un build + // propre : seul ce qui demande une décision est réimprimé. + if (texte.isNotEmpty() && !texte.startsWith("No issues found")) { + logger.lifecycle(texte) + } + } +} diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index c194e84..39d55b3 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -650,6 +650,8 @@ private fun WaveFlowRoot( onPlayQueueItem = playerViewModel::playQueueItem, onMoveQueueItem = playerViewModel::moveQueueItem, onRemoveQueueItem = playerViewModel::removeQueueItem, + onStartSleepTimer = playerViewModel::startSleepTimer, + onCancelSleepTimer = playerViewModel::cancelSleepTimer, ) } } diff --git a/app/src/main/java/app/waveflow/WaveFlowApp.kt b/app/src/main/java/app/waveflow/WaveFlowApp.kt index 05b8836..800a5be 100644 --- a/app/src/main/java/app/waveflow/WaveFlowApp.kt +++ b/app/src/main/java/app/waveflow/WaveFlowApp.kt @@ -2,6 +2,7 @@ package app.waveflow import android.app.Application import android.os.Build +import android.os.SystemClock import app.waveflow.data.LibraryStore import app.waveflow.data.MediaStoreMusicRepository import app.waveflow.data.MusicRepository @@ -22,6 +23,7 @@ import app.waveflow.data.remote.ServerSessionRepository import app.waveflow.playback.Media3PlaybackController import app.waveflow.playback.PlaybackController import app.waveflow.playback.RemoteMediaCache +import app.waveflow.playback.SleepTimer import coil.ImageLoader import coil.ImageLoaderFactory import kotlinx.coroutines.CoroutineScope @@ -97,6 +99,15 @@ class AppContainer(app: Application) { */ fun createPlaybackController(): PlaybackController = Media3PlaybackController(appContext) + /** + * La minuterie de veille, portée par l'application et non par le service. + * + * On la règle depuis l'écran de lecture puis on quitte souvent + * l'application elle-même : elle doit survivre à l'écran. Le service écoute + * ses expirations pour mettre en pause ; elle ne connaît pas le lecteur. + */ + val sleepTimer = SleepTimer(applicationScope, SystemClock::elapsedRealtime) + /** * Cache des pistes distantes, unique pour le processus. * diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index 36afe0c..f73cfc8 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -105,12 +105,31 @@ class PlaybackService : MediaLibraryService() { .build() player.addListener(historyListener(container.playHistoryRepository)) + observeSleepTimer(container.sleepTimer, 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. observeLibrary(container) } + /** + * Met la lecture en pause quand la minuterie de veille arrive à échéance. + * + * Une pause et non un arrêt : on se rendort rarement pour de bon, et + * reprendre là où l'on s'est endormi vaut mieux que de retrouver une file + * vide. La minuterie ignore tout du lecteur — c'est le service, qui le + * tient, qui fait le geste. + * + * L'abonnement vit dans [artworkScope], donc tombe avec le service. Une + * minuterie qui expirerait après lui n'aurait de toute façon plus rien à + * mettre en pause. + */ + private fun observeSleepTimer(timer: SleepTimer, player: Player) { + artworkScope.launch { + timer.expirations.collect { player.pause() } + } + } + /** * Note ce qu'on écoute, mais pas ce qu'on saute. * diff --git a/app/src/main/java/app/waveflow/playback/SleepTimer.kt b/app/src/main/java/app/waveflow/playback/SleepTimer.kt new file mode 100644 index 0000000..33b7336 --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/SleepTimer.kt @@ -0,0 +1,98 @@ +package app.waveflow.playback + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * L'heure à laquelle la lecture doit s'arrêter d'elle-même. + * + * Elle ne connaît pas le lecteur : elle dit **quand**, pas **quoi faire**. Le + * service écoute [expirations] et met en pause ; l'écran lit [endsAtMs] pour + * savoir s'il faut allumer son icône. Ainsi la question « quand faut-il + * s'arrêter » s'éprouve sans démarrer de service ni de lecteur. + * + * Portée par l'application et non par le service : on règle une minuterie puis + * on quitte l'écran, et souvent l'application elle-même. Elle vit donc aussi + * longtemps que le processus — ce qui suffit, puisque la lecture s'arrête avec + * lui de toute façon. + * + * **L'instant de fin plutôt qu'un décompte.** Un `StateFlow` du temps restant + * demanderait une coroutine qui l'entretient à la seconde, pour un affichage + * que personne ne regarde la plupart du temps. L'échéance, elle, ne bouge pas + * tant que la minuterie n'est pas retouchée : qui veut un décompte le dérive. + * + * @param nowMs horloge injectée, pour que les tests mesurent le temps même où + * leurs `delay` s'écoulent. En production, `elapsedRealtime` — elle ne recule + * pas quand l'horloge du téléphone est remise à l'heure, ce qui écourterait + * ou prolongerait une minuterie en cours. + */ +class SleepTimer( + private val scope: CoroutineScope, + private val nowMs: () -> Long, +) { + + private val _endsAtMs = MutableStateFlow(null) + + /** L'échéance, ou `null` si aucune minuterie ne court. */ + val endsAtMs: StateFlow = _endsAtMs.asStateFlow() + + /** + * Émet quand l'échéance est atteinte, jamais quand elle est annulée. + * + * Un `StateFlow` ne suffirait pas : `endsAtMs` retombe à `null` dans les + * deux cas, et le service ne saurait pas s'il doit mettre en pause ou s'il + * vient d'obéir à l'utilisateur. `replay = 0` parce qu'un abonné qui + * arrive après coup n'a rien à rattraper — l'arrêt a déjà eu lieu. + */ + private val _expirations = MutableSharedFlow() + val expirations: SharedFlow = _expirations.asSharedFlow() + + private var job: Job? = null + + /** + * Arme la minuterie pour [durationMs], en remplaçant celle qui courait. + * + * Une durée nulle ou négative ne décrit aucune attente : elle annule, plutôt + * que d'arrêter la lecture sur-le-champ — ce que personne ne demande en + * réglant une minuterie. + */ + fun start(durationMs: Long) { + cancel() + if (durationMs <= 0L) return + + _endsAtMs.value = nowMs() + durationMs + job = scope.launch { + delay(durationMs) + // Remis à zéro **avant** de prévenir : un abonné qui regarde l'état + // en réagissant doit voir une minuterie éteinte, pas une échéance + // déjà passée. + _endsAtMs.value = null + job = null + _expirations.emit(Unit) + } + } + + /** Éteint la minuterie sans arrêter la lecture. */ + fun cancel() { + job?.cancel() + job = null + _endsAtMs.value = null + } + + /** + * Ce qu'il reste à attendre, ou `null` si aucune minuterie ne court. + * + * Jamais négatif : entre l'échéance et le réveil de la coroutine, il + * s'écoule un instant pendant lequel un décompte afficherait un temps à + * rebours. + */ + fun remainingMs(): Long? = _endsAtMs.value?.let { (it - nowMs()).coerceAtLeast(0L) } +} 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 701b533..f6abb1e 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -79,12 +80,17 @@ fun NowPlayingScreen( onPlayQueueItem: (Int) -> Unit, onMoveQueueItem: (from: Int, to: Int) -> Unit, onRemoveQueueItem: (Int) -> Unit, + onStartSleepTimer: (Long) -> Unit, + onCancelSleepTimer: () -> Unit, modifier: Modifier = Modifier, ) { // Local et non remonté : voir la file est une façon de regarder le lecteur, // pas un état de l'application. Refermer le lecteur la referme. var queueShown by rememberSaveable { mutableStateOf(false) } + // Même raison : la feuille est un geste en cours, pas un état à conserver. + var sleepSheetShown by rememberSaveable { mutableStateOf(false) } + // La file peut se vider pendant l'animation de fermeture : on continue // d'afficher le dernier morceau connu le temps que l'écran redescende, // plutôt que de le faire disparaître d'un coup. @@ -116,6 +122,8 @@ fun NowPlayingScreen( queueShown = queueShown, upNextCount = state.upNextCount, onToggleQueue = { queueShown = !queueShown }, + sleepTimerRemainingMs = state.sleepTimerRemainingMs, + onOpenSleepTimer = { sleepSheetShown = true }, ) if (queueShown) { @@ -171,6 +179,21 @@ fun NowPlayingScreen( Spacer(Modifier.height(32.dp)) } + + if (sleepSheetShown) { + SleepTimerSheet( + remainingMs = state.sleepTimerRemainingMs, + onPick = { duree -> + onStartSleepTimer(duree) + sleepSheetShown = false + }, + onCancelTimer = { + onCancelSleepTimer() + sleepSheetShown = false + }, + onDismiss = { sleepSheetShown = false }, + ) + } } } @@ -181,6 +204,8 @@ private fun PlayerHeader( queueShown: Boolean, upNextCount: Int, onToggleQueue: () -> Unit, + sleepTimerRemainingMs: Long?, + onOpenSleepTimer: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -212,6 +237,23 @@ private fun PlayerHeader( ) } + IconButton(onClick = onOpenSleepTimer) { + Icon( + imageVector = Icons.Filled.Bedtime, + // Le temps restant est dans la description plutôt qu'affiché : + // le décompte à la minute encombrerait un en-tête déjà chargé, + // et un lecteur d'écran a besoin du chiffre, pas d'une teinte. + contentDescription = sleepTimerRemainingMs + ?.let { "Minuterie de veille — arrêt dans ${formatRemaining(it)}" } + ?: "Minuterie de veille", + tint = if (sleepTimerRemainingMs != null) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + // Le contrepoids du bouton de gauche devient utile : il ouvre la file. IconButton(onClick = onToggleQueue) { Icon( 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 48efa23..b844136 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -9,6 +9,11 @@ import app.waveflow.playback.RepeatMode * @property track morceau courant, `null` quand rien n'est chargé — dans ce cas * le lecteur ne s'affiche pas du tout. Décrit par le lecteur lui-même et non * résolu dans la bibliothèque : il peut venir d'un serveur. + * @property sleepTimerRemainingMs ce qu'il reste avant l'arrêt automatique, + * `null` si aucune minuterie ne court. Relevé à chaque tic de position, donc + * rafraîchi tant que la lecture avance ; en pause il se fige, alors que la + * minuterie, elle, continue de courir — c'est une heure de coucher, pas un + * quota d'écoute. */ data class PlayerUiState( val track: PlayingTrack? = null, @@ -20,7 +25,12 @@ data class PlayerUiState( val repeatMode: RepeatMode = RepeatMode.Off, val queue: List = emptyList(), val queueIndex: Int = -1, + val sleepTimerRemainingMs: Long? = null, ) { + /** Une minuterie court : l'écran l'indique sans avoir à lire le décompte. */ + val sleepTimerActive: Boolean + get() = sleepTimerRemainingMs != null + /** Ce qui reste à jouer après le morceau courant. */ val upNextCount: Int get() = (queue.size - queueIndex - 1).coerceAtLeast(0) 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 c090b88..b335d54 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -10,9 +10,11 @@ import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackFailure +import app.waveflow.playback.SleepTimer import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map @@ -27,14 +29,19 @@ import kotlinx.coroutines.flow.stateIn */ class PlayerViewModel( private val playbackController: PlaybackController, + private val sleepTimer: SleepTimer, ) : ViewModel() { // Plus de croisement avec la bibliothèque : le lecteur décrit lui-même sa // piste, ce qui vaut aussi pour celles du serveur, absentes du MediaStore. // Cette projection est réévaluée à chaque tic de position — la garder sans // recherche est ce qui la rend gratuite. + // + // La minuterie s'y joint plutôt que d'être un flux à part : son décompte + // n'a de sens qu'à côté du reste, et les tics de position le rafraîchissent + // sans qu'elle ait à entretenir une horloge pour l'affichage. val state: StateFlow = - playbackController.state.map { playback -> + combine(playbackController.state, sleepTimer.endsAtMs) { playback, _ -> PlayerUiState( track = playback.current, isPlaying = playback.isPlaying, @@ -45,6 +52,7 @@ class PlayerViewModel( repeatMode = playback.repeatMode, queue = playback.queue, queueIndex = playback.queueIndex, + sleepTimerRemainingMs = sleepTimer.remainingMs(), ) }.stateIn( scope = viewModelScope, @@ -137,6 +145,12 @@ class PlayerViewModel( fun removeQueueItem(index: Int) = playbackController.removeQueueItem(index) + /** Arme la minuterie de veille, en remplaçant celle qui courait. */ + fun startSleepTimer(durationMs: Long) = sleepTimer.start(durationMs) + + /** Éteint la minuterie sans toucher à la lecture en cours. */ + fun cancelSleepTimer() = sleepTimer.cancel() + override fun onCleared() { // Le service, lui, survit et continue la lecture en arrière-plan. playbackController.release() @@ -151,6 +165,7 @@ class PlayerViewModel( val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as WaveFlowApp PlayerViewModel( playbackController = app.container.createPlaybackController(), + sleepTimer = app.container.sleepTimer, ) } } diff --git a/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt b/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt new file mode 100644 index 0000000..a6664bd --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt @@ -0,0 +1,101 @@ +package app.waveflow.ui.player + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlin.time.Duration.Companion.minutes + +/** + * Les durées proposées. + * + * Cinq minutes pour la sieste, une heure pour la nuit ; au-delà, l'utilisateur + * s'est endormi bien avant. Rien de plus fin qu'un quart d'heure : personne ne + * règle son coucher à la minute près, et chaque ligne de plus est une ligne à + * lire dans le noir. + */ +private val DUREES = listOf(5, 15, 30, 45, 60) + +/** + * Le choix d'une minuterie de veille. + * + * Une feuille modale plutôt qu'un menu : les cibles y sont assez grandes pour + * être touchées d'une main, dans un lit, sans regarder. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SleepTimerSheet( + remainingMs: Long?, + onPick: (durationMs: Long) -> Unit, + onCancelTimer: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + Text( + text = "Minuterie de veille", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = remainingMs + ?.let { "Arrêt dans ${formatRemaining(it)}" } + ?: "La lecture s'arrêtera d'elle-même.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(12.dp)) + + DUREES.forEach { minutes -> + ListItem( + headlineContent = { Text("$minutes minutes") }, + modifier = Modifier + .fillMaxWidth() + .clickable { onPick(minutes.minutes.inWholeMilliseconds) }, + ) + } + + if (remainingMs != null) { + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = onCancelTimer, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Text("Annuler la minuterie") + } + } + } + } +} + +/** + * Le temps restant, arrondi à la minute supérieure. + * + * Arrondi vers le haut : afficher « 0 minute » pendant les dernières secondes + * ferait croire que la minuterie est passée sans rien faire. + */ +internal fun formatRemaining(remainingMs: Long): String { + // Division entière arrondie vers le haut : ajouter une minute moins un + // millième de seconde avant de diviser fait basculer tout reste non nul. + val minutes = ((remainingMs + MS_PAR_MINUTE - 1L) / MS_PAR_MINUTE).toInt() + return if (minutes <= 1) "1 minute" else "$minutes minutes" +} + +private const val MS_PAR_MINUTE = 60_000L diff --git a/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt b/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt new file mode 100644 index 0000000..6c5523f --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt @@ -0,0 +1,140 @@ +package app.waveflow.playback + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * La minuterie de veille. + * + * L'horloge est celle de `runTest` : sans cela, le temps virtuel où s'écoulent + * les `delay` et le temps que la minuterie mesure divergeraient, et les tests + * ne prouveraient rien de leurs échéances. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SleepTimerTest { + + @Test + fun `une minuterie armee annonce son echeance`() = runTest { + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + + timer.start(durationMs = 30_000L) + + assertEquals(30_000L, timer.endsAtMs.value) + assertEquals(30_000L, timer.remainingMs()) + } + + @Test + fun `l'echeance atteinte, la minuterie previent puis s'eteint`() = runTest { + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + val recues = mutableListOf() + backgroundScope.launch { timer.expirations.toList(recues) } + runCurrent() + + timer.start(durationMs = 30_000L) + advanceTimeBy(30_001L) + + assertEquals("une seule expiration", 1, recues.size) + assertNull("la minuterie ne court plus", timer.endsAtMs.value) + } + + @Test + fun `avant l'echeance rien n'est annonce`() = runTest { + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + val recues = mutableListOf() + backgroundScope.launch { timer.expirations.toList(recues) } + runCurrent() + + timer.start(durationMs = 30_000L) + advanceTimeBy(29_000L) + + assertEquals(emptyList(), recues) + assertEquals(1_000L, timer.remainingMs()) + } + + @Test + fun `une minuterie annulee ne previent jamais`() = runTest { + // C'est la distinction que porte `expirations` : annuler et arriver à + // échéance vident tous deux l'état, mais un seul doit arrêter la + // lecture. + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + val recues = mutableListOf() + backgroundScope.launch { timer.expirations.toList(recues) } + runCurrent() + + timer.start(durationMs = 30_000L) + advanceTimeBy(10_000L) + timer.cancel() + advanceTimeBy(60_000L) + + assertEquals("annuler n'arrête pas la lecture", emptyList(), recues) + assertNull(timer.endsAtMs.value) + assertNull(timer.remainingMs()) + } + + @Test + fun `rearmer remplace l'echeance precedente`() = runTest { + // Régler « 60 min » après « 15 min » doit donner soixante minutes, et + // surtout ne pas laisser la première minuterie courir en sourdine. + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + val recues = mutableListOf() + backgroundScope.launch { timer.expirations.toList(recues) } + runCurrent() + + timer.start(durationMs = 15_000L) + advanceTimeBy(5_000L) + timer.start(durationMs = 60_000L) + + assertEquals(65_000L, timer.endsAtMs.value) + + // L'ancienne échéance passe sans rien déclencher. + advanceTimeBy(11_000L) + assertEquals("l'ancienne minuterie ne doit plus courir", emptyList(), recues) + + advanceTimeBy(50_000L) + assertEquals(1, recues.size) + } + + @Test + fun `une duree nulle ou negative eteint la minuterie`() = runTest { + // Personne ne demande d'arrêter la lecture sur-le-champ en réglant une + // minuterie : c'est une annulation, pas une échéance immédiate. + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + val recues = mutableListOf() + backgroundScope.launch { timer.expirations.toList(recues) } + runCurrent() + + timer.start(durationMs = 30_000L) + timer.start(durationMs = 0L) + advanceTimeBy(60_000L) + + assertNull(timer.endsAtMs.value) + assertEquals(emptyList(), recues) + } + + @Test + fun `le temps restant ne devient jamais negatif`() = runTest { + // Entre l'échéance et le réveil de la coroutine, il s'écoule un + // instant : un décompte y afficherait un temps à rebours. + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + + timer.start(durationMs = 30_000L) + advanceTimeBy(29_999L) + + assertEquals(1L, timer.remainingMs()) + } + + @Test + fun `sans minuterie il n'y a pas de temps restant`() = runTest { + val timer = SleepTimer(backgroundScope) { testScheduler.currentTime } + + assertNull(timer.endsAtMs.value) + assertNull(timer.remainingMs()) + } +} 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 8f3cc6b..1d7484e 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,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import app.waveflow.playback.PlaybackFailure import app.waveflow.playback.PlaybackState import app.waveflow.playback.PlayingTrack +import app.waveflow.playback.SleepTimer import app.waveflow.playback.TrackSource import app.waveflow.testing.FakePlaybackController import app.waveflow.testing.MainDispatcherRule @@ -14,6 +15,7 @@ import app.waveflow.testing.remoteSong import app.waveflow.testing.song import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -36,9 +38,21 @@ class PlayerViewModelTest { private val remoteSongs = listOf(remoteSong("a"), remoteSong("b"), remoteSong("c")) private val controller = FakePlaybackController() + /** + * Le ViewModel sous test, muni d'une minuterie qui suit l'horloge virtuelle. + * + * Elle est construite par test : sa portée est celle de `runTest`, et une + * minuterie qui survivrait d'un test à l'autre porterait son échéance avec + * elle. + */ + private fun TestScope.playerViewModel() = PlayerViewModel( + playbackController = controller, + sleepTimer = SleepTimer(backgroundScope) { testScheduler.currentTime }, + ) + @Test fun `jouer un morceau met la file demandee et non toute la bibliotheque`() = runTest { - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val albumQueue = songs.take(2) viewModel.playFrom(albumQueue, songs[1]) @@ -48,7 +62,7 @@ class PlayerViewModelTest { @Test fun `jouer un morceau absent de la file ne declenche rien`() = runTest { - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() viewModel.playFrom(songs.take(2), song(id = 99L)) @@ -57,7 +71,7 @@ class PlayerViewModelTest { @Test fun `playFirst sur une file vide ne declenche rien`() = runTest { - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() viewModel.playFirst(emptyList()) @@ -68,7 +82,7 @@ class PlayerViewModelTest { fun `jouer un morceau distant passe par la file distante`() = runTest { // Chemin distinct : les deux catalogues ne partagent ni type ni // identifiant, et la file distante remplace la locale. - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() viewModel.playRemoteFrom(remoteSongs, remoteSongs[2]) @@ -78,7 +92,7 @@ class PlayerViewModelTest { @Test fun `jouer un morceau distant absent de la file ne declenche rien`() = runTest { - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() viewModel.playRemoteFrom(remoteSongs, remoteSong("inconnu")) @@ -87,7 +101,7 @@ class PlayerViewModelTest { @Test fun `l'aleatoire distant passe par la file distante`() = runTest { - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() viewModel.playRemoteShuffled(remoteSongs) @@ -102,7 +116,7 @@ class PlayerViewModelTest { fun `l'etat reprend la piste telle que le lecteur la decrit`() = runTest { // Plus de résolution dans la bibliothèque : une piste du serveur n'y // figure pas, et la chercher ne rendrait rien à afficher. - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} @@ -139,7 +153,7 @@ class PlayerViewModelTest { // Le défaut d'origine : le lecteur s'arrêtait sur une erreur sans que // rien ne l'annonce, la piste restant affichée comme si elle allait // démarrer. - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val messages = mutableListOf() val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.errors.collect { messages += it } @@ -153,7 +167,7 @@ class PlayerViewModelTest { @Test fun `une piste illisible ne fait pas accuser le serveur`() = runTest { - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val messages = mutableListOf() val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.errors.collect { messages += it } @@ -169,7 +183,7 @@ class PlayerViewModelTest { fun `une panne qui dure ne se repete pas`() = runTest { // L'état est republié à chaque tic de position : sans quoi le message // reviendrait deux fois par seconde tant que la panne dure. - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val messages = mutableListOf() val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.errors.collect { messages += it } @@ -187,7 +201,7 @@ class PlayerViewModelTest { fun `un second echec apres reprise se dit de nouveau`() = runTest { // Media3 oublie son erreur quand on le prépare à nouveau : la panne // repasse par `null`, et le second échec doit se voir comme le premier. - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val messages = mutableListOf() val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.errors.collect { messages += it } @@ -207,7 +221,7 @@ class PlayerViewModelTest { // reste dans l'état du lecteur jusqu'à la prochaine préparation : un // flux redérivé par abonné repartirait de cette valeur courante et // redirait l'erreur, à chaque rotation. - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val avant = mutableListOf() val premier = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.errors.collect { avant += it } @@ -229,7 +243,7 @@ class PlayerViewModelTest { @Test fun `un lecteur qui va bien ne dit rien`() = runTest { - val viewModel = PlayerViewModel(controller) + val viewModel = playerViewModel() val messages = mutableListOf() val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.errors.collect { messages += it } @@ -248,7 +262,7 @@ class PlayerViewModelTest { val viewModelStore = ViewModelStore() val provider = ViewModelProvider( viewModelStore, - viewModelFactory { initializer { PlayerViewModel(controller) } }, + viewModelFactory { initializer { playerViewModel() } }, ) provider[PlayerViewModel::class.java] advanceUntilIdle() @@ -257,4 +271,37 @@ class PlayerViewModelTest { assertTrue("une liaison vivante empêcherait le service de s'arrêter", controller.released) } + + @Test + fun `la minuterie armee apparait dans l'etat du lecteur`() = runTest { + // L'écran allume son icône à partir de là : sans cette remontée, la + // minuterie tournerait sans que rien ne le dise. + val viewModel = playerViewModel() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + viewModel.startSleepTimer(30 * 60_000L) + advanceUntilIdle() + + assertTrue(viewModel.state.value.sleepTimerActive) + assertEquals(30 * 60_000L, viewModel.state.value.sleepTimerRemainingMs) + + job.cancel() + } + + @Test + fun `annuler la minuterie l'efface de l'etat`() = runTest { + val viewModel = playerViewModel() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + viewModel.startSleepTimer(30 * 60_000L) + advanceUntilIdle() + viewModel.cancelSleepTimer() + advanceUntilIdle() + + assertNull(viewModel.state.value.sleepTimerRemainingMs) + + job.cancel() + } } diff --git a/app/src/test/java/app/waveflow/ui/player/SleepTimerSheetTest.kt b/app/src/test/java/app/waveflow/ui/player/SleepTimerSheetTest.kt new file mode 100644 index 0000000..8311460 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/player/SleepTimerSheetTest.kt @@ -0,0 +1,30 @@ +package app.waveflow.ui.player + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Le décompte tel qu'il se dit, à l'écran comme au lecteur d'écran. */ +class SleepTimerSheetTest { + + @Test + fun `le temps restant s'arrondit a la minute superieure`() { + // Vers le haut : « 29 minutes » alors qu'il en reste 29 et demie + // ferait croire que la minuterie a déjà mangé une minute. + assertEquals("30 minutes", formatRemaining(29 * 60_000L + 30_000L)) + assertEquals("30 minutes", formatRemaining(30 * 60_000L)) + } + + @Test + fun `les dernieres secondes se disent une minute, jamais zero`() { + // « 0 minute » ferait croire que la minuterie est passée sans rien + // faire, alors qu'elle est sur le point d'arrêter la lecture. + assertEquals("1 minute", formatRemaining(1_000L)) + assertEquals("1 minute", formatRemaining(0L)) + } + + @Test + fun `le singulier est respecte`() { + assertEquals("1 minute", formatRemaining(60_000L)) + assertEquals("2 minutes", formatRemaining(61_000L)) + } +} From 29b39b41a732fe6b1e6251d6df2d7edd6f92c10d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 10:04:35 +0200 Subject: [PATCH 2/3] fix(lecteur): fermer une course sur la minuterie, et deux angles morts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois des quatre retours de la revue étaient fondés ; le quatrième ne l'était pas, mais indiquait une fragilité réelle. **La course.** `Job.cancel()` ne rattrape pas une coroutine déjà repartie du `delay`. Réarmer la minuterie au moment exact où elle expire laissait l'ancienne effacer la nouvelle échéance, perdre la référence du nouveau `Job` — devenu inannulable — et mettre la lecture en pause alors qu'on venait de demander une heure de plus. C'est le motif d'écartement déjà corrigé en #31 et #34, et je l'avais réintroduit. Chaque minuterie porte désormais son numéro et ne touche à l'état que si c'est encore le sien. **Le décompte figé.** `PlayerUiState` n'est reconstruit qu'aux tics de position, donc plus du tout en pause — alors que la minuterie continue de courir. L'état ne porte plus qu'un booléen, et le décompte se demande à la minuterie au moment de l'afficher : la feuille le relit à la seconde tant qu'elle est ouverte. La description de l'icône ne l'annonce plus du tout, plutôt que d'annoncer une valeur qu'on ne sait pas tenir à jour. **Le rapport de lint quand le lint échoue.** `doLast` est sauté si la tâche échoue — précisément quand on veut savoir pourquoi. Vérifié en désactivant l'opt-in Media3 : la tâche finalisatrice, elle, s'exécute et imprime. En revanche `lintDebug` n'est jamais `UP-TO-DATE`, contrairement à ce que la revue avançait : c'est `lintReportDebug` qui l'est. **`advanceUntilIdle` ne faisait pas expirer la minuterie** — mesuré, le temps virtuel restait à zéro : depuis kotlinx-coroutines 1.7, il ignore les tâches de `backgroundScope`. Le test passait donc pour la bonne raison, mais reposait sur une subtilité. `runCurrent()` dit ce qu'on veut dire. Claude-Session: https://claude.ai/code/session_01Fy19suuEYZBqct32VbtQL7 --- app/build.gradle.kts | 20 ++++++++--- .../main/java/app/waveflow/MainActivity.kt | 1 + .../java/app/waveflow/playback/SleepTimer.kt | 36 +++++++++++++++++-- .../waveflow/ui/player/NowPlayingScreen.kt | 25 +++++++------ .../app/waveflow/ui/player/PlayerUiState.kt | 15 ++++---- .../app/waveflow/ui/player/PlayerViewModel.kt | 13 +++++-- .../app/waveflow/ui/player/SleepTimerSheet.kt | 24 +++++++++++-- .../waveflow/ui/player/PlayerViewModelTest.kt | 13 ++++--- 8 files changed, 112 insertions(+), 35 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0a4fc49..965ad04 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -136,12 +136,19 @@ dependencies { * Le fichier est donc lu et réimprimé. Le chemin est résolu à la configuration, * hors du `doLast`, pour rester compatible avec le cache de configuration. * - * Une seule tâche, nommée exactement : AGP en crée plusieurs qui commencent par - * `lint` — `lintReportDebug`, `lintAnalyzeDebug` — et les prendre toutes - * imprimait le rapport trois fois. + * Une tâche à part, et non un `doLast` sur `lintDebug` : les actions d'une tâche + * sont sautées si elle échoue, c'est-à-dire précisément quand le lint a trouvé + * une erreur et qu'on veut savoir laquelle. `finalizedBy` s'exécute dans les + * deux cas. + * + * `upToDateWhen { false }` parce que cette tâche ne produit rien : son travail + * est d'imprimer, et une tâche sans sortie serait tenue pour à jour. */ -tasks.matching { it.name == "lintDebug" }.configureEach { +val afficherRapportLint = tasks.register("afficherRapportLint") { + description = "Réimprime le rapport texte du lint dans le journal du build." val rapport = layout.buildDirectory.file("reports/lint-results-debug.txt") + outputs.upToDateWhen { false } + doLast { val fichier = rapport.get().asFile if (!fichier.exists()) return@doLast @@ -154,3 +161,8 @@ tasks.matching { it.name == "lintDebug" }.configureEach { } } } + +// Nommée exactement : AGP crée plusieurs tâches qui commencent par `lint` — +// `lintReportDebug`, `lintAnalyzeDebug` — et les prendre toutes imprimait le +// rapport trois fois. +tasks.matching { it.name == "lintDebug" }.configureEach { finalizedBy(afficherRapportLint) } diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 39d55b3..0ab4917 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -652,6 +652,7 @@ private fun WaveFlowRoot( onRemoveQueueItem = playerViewModel::removeQueueItem, onStartSleepTimer = playerViewModel::startSleepTimer, onCancelSleepTimer = playerViewModel::cancelSleepTimer, + onSleepTimerRemainingMs = playerViewModel::sleepTimerRemainingMs, ) } } diff --git a/app/src/main/java/app/waveflow/playback/SleepTimer.kt b/app/src/main/java/app/waveflow/playback/SleepTimer.kt index 33b7336..fd3a0ab 100644 --- a/app/src/main/java/app/waveflow/playback/SleepTimer.kt +++ b/app/src/main/java/app/waveflow/playback/SleepTimer.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicInteger /** * L'heure à laquelle la lecture doit s'arrêter d'elle-même. @@ -57,6 +58,22 @@ class SleepTimer( private var job: Job? = null + /** + * Numéro de la minuterie courante. + * + * `Job.cancel()` ne suffit pas : entre le réveil du `delay` et les lignes + * qui suivent, la coroutine est déjà repartie et l'annulation ne la + * rattrape plus. Elle effacerait alors l'échéance qu'un réarmement vient de + * poser, perdrait la référence du nouveau `Job` — devenu inannulable — et + * mettrait la lecture en pause alors qu'on vient de demander une heure de + * plus. Chaque minuterie porte donc son numéro et ne touche à l'état que si + * c'est encore le sien. + * + * Atomique parce que le numéro s'incrémente depuis le fil qui règle la + * minuterie et se lit depuis celui où le `delay` s'achève. + */ + private val generation = AtomicInteger(0) + /** * Arme la minuterie pour [durationMs], en remplaçant celle qui courait. * @@ -65,26 +82,41 @@ class SleepTimer( * réglant une minuterie. */ fun start(durationMs: Long) { - cancel() + val mien = eteindre() if (durationMs <= 0L) return _endsAtMs.value = nowMs() + durationMs job = scope.launch { delay(durationMs) + // Une minuterie périmée se tait : elle a été remplacée ou annulée + // pendant qu'elle attendait. + if (generation.get() != mien) return@launch + // Remis à zéro **avant** de prévenir : un abonné qui regarde l'état // en réagissant doit voir une minuterie éteinte, pas une échéance // déjà passée. _endsAtMs.value = null - job = null _expirations.emit(Unit) } } /** Éteint la minuterie sans arrêter la lecture. */ fun cancel() { + eteindre() + } + + /** + * Éteint ce qui court et ouvre un nouveau numéro. + * + * Rend ce numéro pour que [start] le confie à la minuterie qu'il arme : + * c'est ce qui permet à celle-ci de reconnaître, en s'éveillant, si elle est + * toujours la bonne. + */ + private fun eteindre(): Int { job?.cancel() job = null _endsAtMs.value = null + return generation.incrementAndGet() } /** 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 f6abb1e..6c70f31 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -82,6 +82,7 @@ fun NowPlayingScreen( onRemoveQueueItem: (Int) -> Unit, onStartSleepTimer: (Long) -> Unit, onCancelSleepTimer: () -> Unit, + onSleepTimerRemainingMs: () -> Long?, modifier: Modifier = Modifier, ) { // Local et non remonté : voir la file est une façon de regarder le lecteur, @@ -122,7 +123,7 @@ fun NowPlayingScreen( queueShown = queueShown, upNextCount = state.upNextCount, onToggleQueue = { queueShown = !queueShown }, - sleepTimerRemainingMs = state.sleepTimerRemainingMs, + sleepTimerActive = state.sleepTimerActive, onOpenSleepTimer = { sleepSheetShown = true }, ) @@ -182,7 +183,7 @@ fun NowPlayingScreen( if (sleepSheetShown) { SleepTimerSheet( - remainingMs = state.sleepTimerRemainingMs, + remainingMs = onSleepTimerRemainingMs, onPick = { duree -> onStartSleepTimer(duree) sleepSheetShown = false @@ -204,7 +205,7 @@ private fun PlayerHeader( queueShown: Boolean, upNextCount: Int, onToggleQueue: () -> Unit, - sleepTimerRemainingMs: Long?, + sleepTimerActive: Boolean, onOpenSleepTimer: () -> Unit, ) { Row( @@ -240,13 +241,17 @@ private fun PlayerHeader( IconButton(onClick = onOpenSleepTimer) { Icon( imageVector = Icons.Filled.Bedtime, - // Le temps restant est dans la description plutôt qu'affiché : - // le décompte à la minute encombrerait un en-tête déjà chargé, - // et un lecteur d'écran a besoin du chiffre, pas d'une teinte. - contentDescription = sleepTimerRemainingMs - ?.let { "Minuterie de veille — arrêt dans ${formatRemaining(it)}" } - ?: "Minuterie de veille", - tint = if (sleepTimerRemainingMs != null) { + // L'état, pas le décompte : celui-ci ne se rafraîchit qu'au + // rythme des tics de position, donc plus du tout en pause, et + // annoncer « arrêt dans 30 minutes » un quart d'heure après + // vaudrait moins que de ne rien annoncer. Le chiffre à jour est + // dans la feuille, qui, elle, tique. + contentDescription = if (sleepTimerActive) { + "Minuterie de veille active" + } else { + "Minuterie de veille" + }, + tint = if (sleepTimerActive) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant 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 b844136..420b4b2 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -9,11 +9,11 @@ import app.waveflow.playback.RepeatMode * @property track morceau courant, `null` quand rien n'est chargé — dans ce cas * le lecteur ne s'affiche pas du tout. Décrit par le lecteur lui-même et non * résolu dans la bibliothèque : il peut venir d'un serveur. - * @property sleepTimerRemainingMs ce qu'il reste avant l'arrêt automatique, - * `null` si aucune minuterie ne court. Relevé à chaque tic de position, donc - * rafraîchi tant que la lecture avance ; en pause il se fige, alors que la - * minuterie, elle, continue de courir — c'est une heure de coucher, pas un - * quota d'écoute. + * @property sleepTimerActive une minuterie de veille court. Un booléen et non le + * temps restant : l'état n'est reconstruit qu'aux tics de position, donc plus + * du tout quand la lecture est en pause, alors que la minuterie continue de + * courir. Le décompte se demande à la minuterie au moment de l'afficher — ce + * que fait la feuille de réglage, seul endroit où quelqu'un le lit. */ data class PlayerUiState( val track: PlayingTrack? = null, @@ -25,11 +25,8 @@ data class PlayerUiState( val repeatMode: RepeatMode = RepeatMode.Off, val queue: List = emptyList(), val queueIndex: Int = -1, - val sleepTimerRemainingMs: Long? = null, + val sleepTimerActive: Boolean = false, ) { - /** Une minuterie court : l'écran l'indique sans avoir à lire le décompte. */ - val sleepTimerActive: Boolean - get() = sleepTimerRemainingMs != null /** Ce qui reste à jouer après le morceau courant. */ val upNextCount: Int 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 b335d54..96e4774 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -41,7 +41,7 @@ class PlayerViewModel( // n'a de sens qu'à côté du reste, et les tics de position le rafraîchissent // sans qu'elle ait à entretenir une horloge pour l'affichage. val state: StateFlow = - combine(playbackController.state, sleepTimer.endsAtMs) { playback, _ -> + combine(playbackController.state, sleepTimer.endsAtMs) { playback, endsAt -> PlayerUiState( track = playback.current, isPlaying = playback.isPlaying, @@ -52,7 +52,7 @@ class PlayerViewModel( repeatMode = playback.repeatMode, queue = playback.queue, queueIndex = playback.queueIndex, - sleepTimerRemainingMs = sleepTimer.remainingMs(), + sleepTimerActive = endsAt != null, ) }.stateIn( scope = viewModelScope, @@ -151,6 +151,15 @@ class PlayerViewModel( /** Éteint la minuterie sans toucher à la lecture en cours. */ fun cancelSleepTimer() = sleepTimer.cancel() + /** + * Ce qu.il reste avant l.arrêt automatique, lu à l.instant de la demande. + * + * Une fonction et non un champ de [PlayerUiState] : celui-ci n.est + * reconstruit qu.aux tics de position, donc plus du tout en pause, alors que + * la minuterie continue de courir. + */ + fun sleepTimerRemainingMs(): Long? = sleepTimer.remainingMs() + override fun onCleared() { // Le service, lui, survit et continue la lecture en arrière-plan. playbackController.release() diff --git a/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt b/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt index a6664bd..583bb1e 100644 --- a/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt +++ b/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt @@ -13,8 +13,14 @@ import androidx.compose.material3.ModalBottomSheet 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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.minutes /** @@ -36,12 +42,24 @@ private val DUREES = listOf(5, 15, 30, 45, 60) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SleepTimerSheet( - remainingMs: Long?, + remainingMs: () -> Long?, onPick: (durationMs: Long) -> Unit, onCancelTimer: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { + // Le décompte se relit ici, à la seconde, plutôt que de suivre l'état du + // lecteur : celui-ci n'émet plus quand la lecture est en pause, et la + // minuterie, elle, continue de courir. Le tic ne vit que le temps de la + // feuille — c'est le seul moment où quelqu'un lit vraiment le chiffre. + var restant by remember { mutableStateOf(remainingMs()) } + LaunchedEffect(Unit) { + while (true) { + delay(1_000L) + restant = remainingMs() + } + } + ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { Column(modifier = Modifier.padding(bottom = 24.dp)) { Text( @@ -53,7 +71,7 @@ fun SleepTimerSheet( Spacer(Modifier.height(4.dp)) Text( - text = remainingMs + text = restant ?.let { "Arrêt dans ${formatRemaining(it)}" } ?: "La lecture s'arrêtera d'elle-même.", style = MaterialTheme.typography.bodyMedium, @@ -72,7 +90,7 @@ fun SleepTimerSheet( ) } - if (remainingMs != null) { + if (restant != null) { Spacer(Modifier.height(8.dp)) TextButton( onClick = onCancelTimer, 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 1d7484e..cb946d8 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -18,8 +18,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -281,10 +283,10 @@ class PlayerViewModelTest { advanceUntilIdle() viewModel.startSleepTimer(30 * 60_000L) - advanceUntilIdle() + runCurrent() assertTrue(viewModel.state.value.sleepTimerActive) - assertEquals(30 * 60_000L, viewModel.state.value.sleepTimerRemainingMs) + assertEquals(30 * 60_000L, viewModel.sleepTimerRemainingMs()) job.cancel() } @@ -296,11 +298,12 @@ class PlayerViewModelTest { advanceUntilIdle() viewModel.startSleepTimer(30 * 60_000L) - advanceUntilIdle() + runCurrent() viewModel.cancelSleepTimer() - advanceUntilIdle() + runCurrent() - assertNull(viewModel.state.value.sleepTimerRemainingMs) + assertFalse(viewModel.state.value.sleepTimerActive) + assertNull(viewModel.sleepTimerRemainingMs()) job.cancel() } From 8c2e210760cf0836d656db82670884a2385eda52 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 10:24:45 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(lecteur):=20s=C3=A9rialiser=20vraiment?= =?UTF-8?q?=20l'expiration,=20et=20ne=20pas=20mentir=20sur=20la=20couvertu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **La course était encore ouverte.** Comparer le numéro de génération puis agir laissait une fenêtre entre les deux : un réarmement glissé là voyait son échéance effacée par la minuterie qu'il venait de remplacer. J'avais rétréci l'écartement sans le fermer — le motif même que la revue signalait. Reconnaître son numéro, éteindre l'échéance et prévenir tiennent désormais dans un seul verrou, partagé avec `start` et `cancel`. L'émission y entre parce qu'un tampon de un la rend non suspendante. **Le test de cette course a été écrit, puis retiré.** Il passait le retrait de la garde : en temps virtuel mono-fil, `advanceTimeBy` n'exécute pas le `delay` avant le réarmement, si bien que l'ancienne coroutine ne se réveillait jamais et que la garde n'était pas sollicitée. Un test creux de plus, pris par le protocole. La course demande deux fils réels ; elle reste non éprouvée, et c'est écrit à côté du code plutôt que laissé croire. **Le message sans minuterie était trompeur.** « La lecture s'arrêtera d'elle-même » décrivait ce qui arriverait en choisissant une durée, mais se lisait comme si une minuterie courait déjà. Écarté avec raison : passer par `SingleArtifact.LINT_TEXT_REPORT` plutôt que par le chemin du rapport. Cela demanderait une classe de tâche et un `onVariants` pour se prémunir d'un déplacement que rien n'annonce, et la tâche se tairait sans rien casser le jour où il surviendrait. La part utile du retour — la tâche finalisatrice — est en place et vérifiée jusqu'au cas de l'échec du lint. Claude-Session: https://claude.ai/code/session_01Fy19suuEYZBqct32VbtQL7 --- app/build.gradle.kts | 6 ++ .../java/app/waveflow/playback/SleepTimer.kt | 60 ++++++++++++++----- .../app/waveflow/ui/player/SleepTimerSheet.kt | 6 +- .../app/waveflow/playback/SleepTimerTest.kt | 8 +++ 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 965ad04..9920890 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -143,6 +143,12 @@ dependencies { * * `upToDateWhen { false }` parce que cette tâche ne produit rien : son travail * est d'imprimer, et une tâche sans sortie serait tenue pour à jour. + * + * Le chemin est écrit en clair plutôt que pris à `SingleArtifact.LINT_TEXT_REPORT`. + * Passer par l'artefact demanderait une classe de tâche et un `onVariants`, pour + * se prémunir d'un déplacement de fichier que rien n'annonce. À reprendre le + * jour où le chemin bougera — la tâche se taira alors sans rien casser, le + * fichier absent étant traité comme tel. */ val afficherRapportLint = tasks.register("afficherRapportLint") { description = "Réimprime le rapport texte du lint dans le journal du build." diff --git a/app/src/main/java/app/waveflow/playback/SleepTimer.kt b/app/src/main/java/app/waveflow/playback/SleepTimer.kt index fd3a0ab..00a51ec 100644 --- a/app/src/main/java/app/waveflow/playback/SleepTimer.kt +++ b/app/src/main/java/app/waveflow/playback/SleepTimer.kt @@ -53,9 +53,23 @@ class SleepTimer( * vient d'obéir à l'utilisateur. `replay = 0` parce qu'un abonné qui * arrive après coup n'a rien à rattraper — l'arrêt a déjà eu lieu. */ - private val _expirations = MutableSharedFlow() + private val _expirations = MutableSharedFlow(extraBufferCapacity = 1) val expirations: SharedFlow = _expirations.asSharedFlow() + /** + * Sérialise la transition d'expiration avec les réglages de l'utilisateur. + * + * Comparer le numéro puis agir ne suffit pas : c'est leur **écartement** qui + * laisse passer. Un réarmement glissé entre les deux verrait son échéance + * effacée par la minuterie qu'il vient de remplacer, et la lecture + * s'arrêterait alors qu'on demandait une heure de plus. + * + * L'émission tient dans le verrou parce que le tampon la rend + * non suspendante : `tryEmit` accepte toujours, la capacité couvrant + * l'expiration unique qu'une minuterie peut produire. + */ + private val verrou = Any() + private var job: Job? = null /** @@ -82,27 +96,39 @@ class SleepTimer( * réglant une minuterie. */ fun start(durationMs: Long) { - val mien = eteindre() - if (durationMs <= 0L) return - - _endsAtMs.value = nowMs() + durationMs - job = scope.launch { - delay(durationMs) - // Une minuterie périmée se tait : elle a été remplacée ou annulée - // pendant qu'elle attendait. - if (generation.get() != mien) return@launch + synchronized(verrou) { + val mien = eteindre() + if (durationMs <= 0L) return - // Remis à zéro **avant** de prévenir : un abonné qui regarde l'état - // en réagissant doit voir une minuterie éteinte, pas une échéance - // déjà passée. - _endsAtMs.value = null - _expirations.emit(Unit) + _endsAtMs.value = nowMs() + durationMs + job = scope.launch { + delay(durationMs) + expirer(mien) + } } } + /** + * Constate l'échéance, si cette minuterie est encore celle qui court. + * + * Tout tient dans le verrou : reconnaître son numéro, éteindre l'échéance et + * prévenir. Une minuterie périmée — remplacée ou annulée pendant qu'elle + * attendait — repart sans rien toucher. + * + * L'échéance est effacée **avant** que l'on prévienne : un abonné qui + * regarde l'état en réagissant doit voir une minuterie éteinte, pas une + * heure déjà passée. + */ + private fun expirer(mien: Int) = synchronized(verrou) { + if (generation.get() != mien) return@synchronized + + _endsAtMs.value = null + _expirations.tryEmit(Unit) + } + /** Éteint la minuterie sans arrêter la lecture. */ fun cancel() { - eteindre() + synchronized(verrou) { eteindre() } } /** @@ -111,6 +137,8 @@ class SleepTimer( * Rend ce numéro pour que [start] le confie à la minuterie qu'il arme : * c'est ce qui permet à celle-ci de reconnaître, en s'éveillant, si elle est * toujours la bonne. + * + * À n'appeler que sous [verrou]. */ private fun eteindre(): Int { job?.cancel() diff --git a/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt b/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt index 583bb1e..ce73f9e 100644 --- a/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt +++ b/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt @@ -71,9 +71,13 @@ fun SleepTimerSheet( Spacer(Modifier.height(4.dp)) Text( + // Sans minuterie, dire qu'il n'y en a pas : « la lecture + // s'arrêtera d'elle-même » décrivait ce qui *arriverait* en + // choisissant une durée, mais se lisait comme si une minuterie + // courait déjà. text = restant ?.let { "Arrêt dans ${formatRemaining(it)}" } - ?: "La lecture s'arrêtera d'elle-même.", + ?: "Aucune minuterie : la lecture continuera.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp), diff --git a/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt b/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt index 6c5523f..fc093ba 100644 --- a/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt +++ b/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt @@ -118,6 +118,14 @@ class SleepTimerTest { assertEquals(emptyList(), recues) } + // La course entre une expiration et un réarmement concurrent n'a pas de + // test, et n'en aura pas ici : elle demande deux fils, quand le scheduler de + // `runTest` est mono-fil. Un test écrit pour elle passait le retrait de la + // garde — `advanceTimeBy` n'exécute pas le `delay` avant le réarmement, si + // bien que l'ancienne coroutine ne se réveillait jamais et que la garde + // n'était pas sollicitée. Il a été retiré plutôt que de laisser croire la + // zone couverte. La protection est décrite dans `SleepTimer.verrou`. + @Test fun `le temps restant ne devient jamais negatif`() = runTest { // Entre l'échéance et le réveil de la coroutine, il s'écoule un