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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment thread
InstaZDLL marked this conversation as resolved.
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) }
3 changes: 3 additions & 0 deletions app/src/main/java/app/waveflow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/app/waveflow/WaveFlowApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/app/waveflow/playback/PlaybackService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
158 changes: 158 additions & 0 deletions app/src/main/java/app/waveflow/playback/SleepTimer.kt
Original file line number Diff line number Diff line change
@@ -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<Long?>(null)

/** L'échéance, ou `null` si aucune minuterie ne court. */
val endsAtMs: StateFlow<Long?> = _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<Unit>(extraBufferCapacity = 1)
val expirations: SharedFlow<Unit> = _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) }
}
47 changes: 47 additions & 0 deletions app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -116,6 +123,8 @@ fun NowPlayingScreen(
queueShown = queueShown,
upNextCount = state.upNextCount,
onToggleQueue = { queueShown = !queueShown },
sleepTimerActive = state.sleepTimerActive,
onOpenSleepTimer = { sleepSheetShown = true },
)

if (queueShown) {
Expand Down Expand Up @@ -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 },
)
}
}
}

Expand All @@ -181,6 +205,8 @@ private fun PlayerHeader(
queueShown: Boolean,
upNextCount: Int,
onToggleQueue: () -> Unit,
sleepTimerActive: Boolean,
onOpenSleepTimer: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
Expand Down Expand Up @@ -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(
Expand Down
Loading