diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d54119e..9920890 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,51 @@ 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 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. + * + * 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." + val rapport = layout.buildDirectory.file("reports/lint-results-debug.txt") + outputs.upToDateWhen { false } + + 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) + } + } +} + +// 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 c194e84..0ab4917 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -650,6 +650,9 @@ private fun WaveFlowRoot( onPlayQueueItem = playerViewModel::playQueueItem, onMoveQueueItem = playerViewModel::moveQueueItem, onRemoveQueueItem = playerViewModel::removeQueueItem, + onStartSleepTimer = playerViewModel::startSleepTimer, + onCancelSleepTimer = playerViewModel::cancelSleepTimer, + onSleepTimerRemainingMs = playerViewModel::sleepTimerRemainingMs, ) } } 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..00a51ec --- /dev/null +++ b/app/src/main/java/app/waveflow/playback/SleepTimer.kt @@ -0,0 +1,158 @@ +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 +import java.util.concurrent.atomic.AtomicInteger + +/** + * 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(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 + + /** + * 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. + * + * 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) { + synchronized(verrou) { + val mien = eteindre() + if (durationMs <= 0L) return + + _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() { + synchronized(verrou) { 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. + * + * À n'appeler que sous [verrou]. + */ + private fun eteindre(): Int { + job?.cancel() + job = null + _endsAtMs.value = null + return generation.incrementAndGet() + } + + /** + * 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..6c70f31 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,18 @@ fun NowPlayingScreen( onPlayQueueItem: (Int) -> Unit, onMoveQueueItem: (from: Int, to: Int) -> Unit, 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, // 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 +123,8 @@ fun NowPlayingScreen( queueShown = queueShown, upNextCount = state.upNextCount, onToggleQueue = { queueShown = !queueShown }, + sleepTimerActive = state.sleepTimerActive, + onOpenSleepTimer = { sleepSheetShown = true }, ) if (queueShown) { @@ -171,6 +180,21 @@ fun NowPlayingScreen( Spacer(Modifier.height(32.dp)) } + + if (sleepSheetShown) { + SleepTimerSheet( + remainingMs = onSleepTimerRemainingMs, + onPick = { duree -> + onStartSleepTimer(duree) + sleepSheetShown = false + }, + onCancelTimer = { + onCancelSleepTimer() + sleepSheetShown = false + }, + onDismiss = { sleepSheetShown = false }, + ) + } } } @@ -181,6 +205,8 @@ private fun PlayerHeader( queueShown: Boolean, upNextCount: Int, onToggleQueue: () -> Unit, + sleepTimerActive: Boolean, + onOpenSleepTimer: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -212,6 +238,27 @@ private fun PlayerHeader( ) } + IconButton(onClick = onOpenSleepTimer) { + Icon( + imageVector = Icons.Filled.Bedtime, + // 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 + }, + ) + } + // 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..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,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 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, @@ -20,7 +25,9 @@ data class PlayerUiState( val repeatMode: RepeatMode = RepeatMode.Off, val queue: List = emptyList(), val queueIndex: Int = -1, + val sleepTimerActive: Boolean = false, ) { + /** 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..96e4774 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, endsAt -> PlayerUiState( track = playback.current, isPlaying = playback.isPlaying, @@ -45,6 +52,7 @@ class PlayerViewModel( repeatMode = playback.repeatMode, queue = playback.queue, queueIndex = playback.queueIndex, + sleepTimerActive = endsAt != null, ) }.stateIn( scope = viewModelScope, @@ -137,6 +145,21 @@ 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() + + /** + * 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() @@ -151,6 +174,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..ce73f9e --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/SleepTimerSheet.kt @@ -0,0 +1,123 @@ +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.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 + +/** + * 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, +) { + // 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( + text = "Minuterie de veille", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + 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)}" } + ?: "Aucune minuterie : la lecture continuera.", + 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 (restant != 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..fc093ba --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/SleepTimerTest.kt @@ -0,0 +1,148 @@ +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) + } + + // 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 + // 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..cb946d8 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,10 +15,13 @@ 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.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 @@ -36,9 +40,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 +64,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 +73,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 +84,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 +94,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 +103,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 +118,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 +155,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 +169,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 +185,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 +203,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 +223,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 +245,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 +264,7 @@ class PlayerViewModelTest { val viewModelStore = ViewModelStore() val provider = ViewModelProvider( viewModelStore, - viewModelFactory { initializer { PlayerViewModel(controller) } }, + viewModelFactory { initializer { playerViewModel() } }, ) provider[PlayerViewModel::class.java] advanceUntilIdle() @@ -257,4 +273,38 @@ 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) + runCurrent() + + assertTrue(viewModel.state.value.sleepTimerActive) + assertEquals(30 * 60_000L, viewModel.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) + runCurrent() + viewModel.cancelSleepTimer() + runCurrent() + + assertFalse(viewModel.state.value.sleepTimerActive) + assertNull(viewModel.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)) + } +}