diff --git a/app/src/main/java/app/waveflow/MainActivity.kt b/app/src/main/java/app/waveflow/MainActivity.kt index 0ab4917..3b580ef 100644 --- a/app/src/main/java/app/waveflow/MainActivity.kt +++ b/app/src/main/java/app/waveflow/MainActivity.kt @@ -653,6 +653,7 @@ private fun WaveFlowRoot( onStartSleepTimer = playerViewModel::startSleepTimer, onCancelSleepTimer = playerViewModel::cancelSleepTimer, onSleepTimerRemainingMs = playerViewModel::sleepTimerRemainingMs, + onSetPlaybackSpeed = playerViewModel::setPlaybackSpeed, ) } } diff --git a/app/src/main/java/app/waveflow/data/PreferencesStore.kt b/app/src/main/java/app/waveflow/data/PreferencesStore.kt index 86895e3..77814e4 100644 --- a/app/src/main/java/app/waveflow/data/PreferencesStore.kt +++ b/app/src/main/java/app/waveflow/data/PreferencesStore.kt @@ -3,12 +3,15 @@ package app.waveflow.data import android.content.Context import android.util.Log import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import app.waveflow.model.AppPreferences +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.ThemeChoice import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -32,6 +35,16 @@ interface PreferencesStore { val preferences: Flow suspend fun setTheme(choice: ThemeChoice) + + /** + * Change la vitesse de lecture. + * + * Écrite ici et non posée sur le lecteur : c'est le service qui observe la + * préférence et l'applique, si bien que le réglage tient même quand aucun + * écran n'est ouvert — la lecture démarrée depuis Android Auto ou la + * notification part à la bonne vitesse. + */ + suspend fun setPlaybackSpeed(speed: Float) } /** @@ -87,27 +100,81 @@ class DataStorePreferencesStore( .catch { emit(emptyPreferences()) } .map { it.toAppPreferences() } - override suspend fun setTheme(choice: ThemeChoice) { - dataStore.edit { it[THEME] = choice.name } + override suspend fun setTheme(choice: ThemeChoice) = + ecrire("Thème") { it[THEME] = choice.name } + + /** + * La vitesse est bornée à l'écriture **et** à la relecture. + * + * Aux deux bouts et non à un seul : borner en écrivant protège le fichier + * de ce que l'appelant apporte, borner en lisant protège l'application de + * ce que le fichier contient déjà — une version future aux bornes plus + * larges, ou un fichier abîmé. + */ + override suspend fun setPlaybackSpeed(speed: Float) = + ecrire("Vitesse de lecture") { it[PLAYBACK_SPEED] = PlaybackSpeed.borner(speed) } + + /** + * Écrit une préférence sans faire tomber celui qui la demande. + * + * L'échec d'écriture est retenu ici, comme l'est déjà celui de lecture. Les + * appelants lancent dans la portée de leur ViewModel, laquelle n'a pas de + * gestionnaire d'exception : un disque plein y ferait tomber l'application + * entière — pour un thème ou une vitesse de lecture. Le choix est alors + * simplement perdu, ce que l'écran dit de lui-même en restant sur l'ancienne + * valeur, le flux n'ayant rien émis. + * + * `IOException` et non tout le reste : une annulation traverse elle aussi + * `edit`, et l'avaler ferait survivre une écriture à la portée qui l'a + * demandée. + * + * @param quoi ce qu'on tentait d'enregistrer, pour que le journal dise + * lequel des réglages a été perdu. + */ + private suspend fun ecrire(quoi: String, transform: (MutablePreferences) -> Unit) { + try { + dataStore.edit(transform) + } catch (erreur: IOException) { + Log.w(TAG, "$quoi non enregistré", erreur) + } } /** - * Un nom de thème inconnu vaut le défaut. + * Ce que le fichier ne sait pas dire vaut le défaut. * - * Le cas se présente si une version future en ajoute un puis qu'on - * redescend : l'ancienne lit une valeur qu'elle ne connaît pas, et lever y - * rendrait l'application inutilisable. + * Un nom de thème inconnu, une vitesse hors bornes : le cas se présente si + * une version future en ajoute puis qu'on redescend, l'ancienne lisant + * alors une valeur qu'elle ne connaît pas. Lever ici rendrait + * l'application inutilisable pour un réglage d'apparence. */ - private fun Preferences.toAppPreferences(): AppPreferences = AppPreferences( - theme = this[THEME] - ?.let { name -> ThemeChoice.entries.firstOrNull { it.name == name } } - ?: AppPreferences().theme, - ) + private fun Preferences.toAppPreferences(): AppPreferences { + // Lues par la carte et non par `this[cle]`, dont le cast n'est pas + // vérifié : une clé portant un autre type que le sien y lève une + // `ClassCastException`. Elle surviendrait **après** le `catch`, posé en + // amont de cette conversion, et emporterait l'application dans la + // portée du ViewModel — pour une préférence. `as?` retombe sur le + // défaut sans rien lever, et sans terminer le flux : le fichier reste + // relu, si bien qu'un autre réglage change encore. + // + // Le cas se présente si une version future change le type d'une clé + // puis qu'on redescend. + val valeurs = asMap() + + return AppPreferences( + theme = (valeurs[THEME] as? String) + ?.let { name -> ThemeChoice.entries.firstOrNull { it.name == name } } + ?: AppPreferences().theme, + playbackSpeed = (valeurs[PLAYBACK_SPEED] as? Float) + ?.let(PlaybackSpeed::borner) + ?: AppPreferences().playbackSpeed, + ) + } private companion object { const val TAG = "PreferencesStore" val THEME = stringPreferencesKey("theme") + val PLAYBACK_SPEED = floatPreferencesKey("playback_speed") /** Trois reprises : de quoi passer un incident, pas une corruption. */ const val MAX_TENTATIVES = 3L diff --git a/app/src/main/java/app/waveflow/model/AppPreferences.kt b/app/src/main/java/app/waveflow/model/AppPreferences.kt index 2c01efe..1672628 100644 --- a/app/src/main/java/app/waveflow/model/AppPreferences.kt +++ b/app/src/main/java/app/waveflow/model/AppPreferences.kt @@ -1,8 +1,15 @@ package app.waveflow.model -/** Ce que l'utilisateur a choisi, et qui survit à la fermeture de l'application. */ +/** + * Ce que l'utilisateur a choisi, et qui survit à la fermeture de l'application. + * + * @property playbackSpeed la vitesse de lecture. Elle se persiste parce qu'elle + * décrit une habitude et non un geste : qui écoute ses podcasts à ×1,5 ne + * veut pas le redire à chaque lancement. Voir [PlaybackSpeed] pour ses bornes. + */ data class AppPreferences( val theme: ThemeChoice = ThemeChoice.System, + val playbackSpeed: Float = PlaybackSpeed.NORMALE, ) /** diff --git a/app/src/main/java/app/waveflow/model/PlaybackSpeed.kt b/app/src/main/java/app/waveflow/model/PlaybackSpeed.kt new file mode 100644 index 0000000..01f14ee --- /dev/null +++ b/app/src/main/java/app/waveflow/model/PlaybackSpeed.kt @@ -0,0 +1,80 @@ +package app.waveflow.model + +/** + * La vitesse de lecture, et ce qui la tient dans des limites raisonnables. + * + * Un nombre et non une énumération comme [ThemeChoice] : la vitesse est une + * grandeur continue, pas un choix parmi des noms. En faire un jeu de constantes + * obligerait à baptiser chaque palier, et interdirait d'ajouter un réglage plus + * fin sans réécrire ce qui est déjà sur le disque. [PROPOSEES] dit ce que + * l'interface offre aujourd'hui ; le format persisté, lui, ne s'y limite pas. + */ +object PlaybackSpeed { + + /** La vitesse d'origine, celle du disque tel qu'il a été gravé. */ + const val NORMALE = 1f + + /** + * Les bornes du réglage. + * + * En dessous de la moitié, l'étirement temporel de Media3 rend une bouillie + * plutôt qu'un ralenti ; au-delà du double, plus personne ne suit une + * parole, et la musique n'y survit pas du tout. + */ + const val MIN = 0.5f + const val MAX = 2f + + /** + * Ce que la feuille de réglage propose. + * + * Des quarts entre l'unité et le double, là où se joue l'écoute d'une + * parole ; la moitié et les trois quarts en dessous, utiles pour repiquer + * un passage à l'instrument. Un pas plus fin ajouterait des lignes sans + * ajouter de choix : personne ne distingue ×1,4 de ×1,45. + */ + val PROPOSEES = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f) + + /** + * Ramène [speed] dans les bornes, et rend [NORMALE] de ce qui n'est pas un + * nombre. + * + * Ce n'est pas une précaution de style : la valeur vient du disque, donc + * potentiellement d'une version future qui aurait élargi les bornes, ou + * d'un fichier abîmé. Une vitesse nulle figerait la lecture sans rien dire, + * et un `NaN` — que `coerceIn` laisse passer tel quel — ferait lever Media3 + * au moment de l'appliquer, loin de l'endroit où il a été lu. + */ + fun borner(speed: Float): Float = + if (speed.isFinite()) speed.coerceIn(MIN, MAX) else NORMALE + + /** + * La vitesse telle qu'elle s'écrit : « ×1 », « ×1,5 », « ×0,75 ». + * + * Formatée à la main plutôt qu'avec `String.format` : celui-ci suit la + * locale de l'appareil, qui décide aussi bien du séparateur décimal que du + * jeu de chiffres. L'interface est en français, la virgule y est donc + * constante — et les chiffres doivent le rester. + * + * Les zéros inutiles tombent : « ×1 » et non « ×1,00 », qui suggérerait une + * précision que le réglage n'offre pas. + */ + fun format(speed: Float): String { + val centiemes = Math.round(borner(speed) * CENTIEMES) + val entier = centiemes / CENTIEMES + val reste = centiemes % CENTIEMES + + val decimales = when { + reste == 0 -> "" + // ×1,50 s'écrit « ×1,5 » : le zéro final ne dit rien de plus. + reste % DIXIEMES == 0 -> ",${reste / DIXIEMES}" + // ×1,05 garde le sien, sans quoi il se lirait « ×1,5 ». + reste < DIXIEMES -> ",0$reste" + else -> ",$reste" + } + + return "×$entier$decimales" + } + + private const val CENTIEMES = 100 + private const val DIXIEMES = 10 +} diff --git a/app/src/main/java/app/waveflow/playback/PlaybackService.kt b/app/src/main/java/app/waveflow/playback/PlaybackService.kt index f73cfc8..9d80449 100644 --- a/app/src/main/java/app/waveflow/playback/PlaybackService.kt +++ b/app/src/main/java/app/waveflow/playback/PlaybackService.kt @@ -13,12 +13,15 @@ import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import app.waveflow.WaveFlowApp import app.waveflow.data.PlayHistoryRepository +import app.waveflow.data.PreferencesStore import coil.imageLoader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch /** @@ -106,6 +109,7 @@ class PlaybackService : MediaLibraryService() { player.addListener(historyListener(container.playHistoryRepository)) observeSleepTimer(container.sleepTimer, player) + observePlaybackSpeed(container.preferencesStore, 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. @@ -130,6 +134,28 @@ class PlaybackService : MediaLibraryService() { } } + /** + * Applique la vitesse de lecture choisie, et la réapplique quand elle change. + * + * Observée ici plutôt que posée par l'écran de lecture : le service joue + * aussi quand aucun écran n'est ouvert — en voiture, depuis la notification + * — et une vitesse qui n'existerait qu'une fois le lecteur affiché + * retomberait à ×1 précisément dans ces cas-là. Le flux rend sa valeur + * courante dès l'abonnement, si bien que la lecture démarre à la bonne + * vitesse sans que personne ait à la redemander. + * + * `distinctUntilChanged` sur la seule vitesse : le flux porte toutes les + * préférences, et changer de thème n'a pas à traverser jusqu'au lecteur. + */ + private fun observePlaybackSpeed(preferences: PreferencesStore, player: Player) { + artworkScope.launch { + preferences.preferences + .map { it.playbackSpeed } + .distinctUntilChanged() + .collect { player.setPlaybackSpeed(it) } + } + } + /** * Note ce qu'on écoute, mais pas ce qu'on saute. * 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 6c70f31..69363c7 100644 --- a/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt +++ b/app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.orUnknownArtist import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode @@ -83,6 +84,7 @@ fun NowPlayingScreen( onStartSleepTimer: (Long) -> Unit, onCancelSleepTimer: () -> Unit, onSleepTimerRemainingMs: () -> Long?, + onSetPlaybackSpeed: (Float) -> Unit, modifier: Modifier = Modifier, ) { // Local et non remonté : voir la file est une façon de regarder le lecteur, @@ -91,6 +93,7 @@ fun NowPlayingScreen( // Même raison : la feuille est un geste en cours, pas un état à conserver. var sleepSheetShown by rememberSaveable { mutableStateOf(false) } + var speedSheetShown 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, @@ -125,6 +128,8 @@ fun NowPlayingScreen( onToggleQueue = { queueShown = !queueShown }, sleepTimerActive = state.sleepTimerActive, onOpenSleepTimer = { sleepSheetShown = true }, + playbackSpeed = state.playbackSpeed, + onOpenPlaybackSpeed = { speedSheetShown = true }, ) if (queueShown) { @@ -195,6 +200,17 @@ fun NowPlayingScreen( onDismiss = { sleepSheetShown = false }, ) } + + if (speedSheetShown) { + PlaybackSpeedSheet( + speed = state.playbackSpeed, + onPick = { vitesse -> + onSetPlaybackSpeed(vitesse) + speedSheetShown = false + }, + onDismiss = { speedSheetShown = false }, + ) + } } } @@ -207,6 +223,8 @@ private fun PlayerHeader( onToggleQueue: () -> Unit, sleepTimerActive: Boolean, onOpenSleepTimer: () -> Unit, + playbackSpeed: Float, + onOpenPlaybackSpeed: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -238,6 +256,8 @@ private fun PlayerHeader( ) } + PlaybackSpeedButton(speed = playbackSpeed, onClick = onOpenPlaybackSpeed) + IconButton(onClick = onOpenSleepTimer) { Icon( imageVector = Icons.Filled.Bedtime, @@ -432,6 +452,42 @@ private fun PlayerControls( } } +/** + * La vitesse de lecture : le bouton **est** son affichage. + * + * Le chiffre plutôt qu'une icône, contrairement à ses voisins. Une vitesse + * active et invisible est un défaut qu'on cherche longtemps — « pourquoi cette + * voix est-elle pressée ? » — et aucune icône ne dit ×1,5. Elle tient dans la + * même empreinte que les autres boutons de la rangée : `IconButton` et non + * `TextButton`, dont la largeur minimale creuserait un trou dans l'alignement. + * + * La teinte reprend la grammaire du reste de l'en-tête : accentuée quand le + * réglage s'écarte de l'ordinaire, éteinte sinon. + */ +@Composable +private fun PlaybackSpeedButton(speed: Float, onClick: () -> Unit) { + val libelle = PlaybackSpeed.format(speed) + val ordinaire = speed == PlaybackSpeed.NORMALE + + IconButton( + onClick = onClick, + // Le texte seul se lirait « fois un virgule cinq » sans qu'on sache de + // quoi ; la description dit la grandeur, et prend le pas sur lui. + modifier = Modifier.semantics { contentDescription = "Vitesse de lecture : $libelle" }, + ) { + Text( + text = libelle, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + color = if (ordinaire) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + ) + } +} + /** Bouton secondaire dont la teinte signale l'état actif. */ @Composable private fun ToggleControl( diff --git a/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt b/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt new file mode 100644 index 0000000..20089e7 --- /dev/null +++ b/app/src/main/java/app/waveflow/ui/player/PlaybackSpeedSheet.kt @@ -0,0 +1,111 @@ +package app.waveflow.ui.player + +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.foundation.selection.selectable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import app.waveflow.model.PlaybackSpeed + +/** + * Le choix d'une vitesse de lecture. + * + * Une feuille et non un bouton qui fait tourner les valeurs : elles sont sept, + * et parcourir six paliers pour revenir à ×1 ferait de chaque essai un aller + * sans retour. + * + * @param speed la vitesse en vigueur, celle qui porte la coche. Elle vient des + * préférences déjà enregistrées et non d'un choix en attente : la feuille + * n'entretient aucun état, ce qui la dispense de le réconcilier avec le + * réglage que le service applique. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaybackSpeedSheet( + speed: Float, + onPick: (speed: Float) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + Text( + text = "Vitesse de lecture", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(4.dp)) + + Text( + // Ce que le réglage engage, dit une fois ici plutôt que deviné : + // il ne vaut pas que pour le morceau en cours, et on le + // retrouvera au lancement suivant. + text = "S'applique à toute la lecture, et se retrouve au prochain lancement.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp), + ) + + Spacer(Modifier.height(12.dp)) + + // La vitesse en cours se joint aux propositions si elle n'y est + // pas. Le format persisté ne se limite pas à cette liste — une + // version future peut offrir un réglage plus fin, et un fichier + // rapporté d'ailleurs porter ×1,1. Sans cela la feuille s'ouvrirait + // sans rien de coché, à contredire le bouton qui l'a ouverte. + val vitesses = (PlaybackSpeed.PROPOSEES + speed).distinct().sorted() + + vitesses.forEach { proposee -> + val choisie = proposee == speed + + ListItem( + headlineContent = { + Text( + text = PlaybackSpeed.format(proposee) + + if (proposee == PlaybackSpeed.NORMALE) " (normale)" else "", + ) + }, + trailingContent = if (choisie) { + { + Icon( + imageVector = Icons.Filled.Check, + // Rien à décrire : `selectable` porte déjà + // l'état sélectionné, et le lecteur d'écran + // annoncerait deux fois la même chose. + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .fillMaxWidth() + // `selectable` et non `clickable` : ces lignes sont les + // valeurs d'un même réglage, et c'est ce qui fait dire + // « sélectionné » au lecteur d'écran plutôt que de + // laisser la coche muette. + .selectable( + selected = choisie, + role = Role.RadioButton, + onClick = { onPick(proposee) }, + ), + ) + } + } + } +} 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 420b4b2..93c1b21 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt @@ -1,5 +1,6 @@ package app.waveflow.ui.player +import app.waveflow.model.PlaybackSpeed import app.waveflow.playback.PlayingTrack import app.waveflow.playback.RepeatMode @@ -14,6 +15,10 @@ import app.waveflow.playback.RepeatMode * 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. + * @property playbackSpeed la vitesse de lecture. Elle vient des préférences et + * non du lecteur : le flux des préférences émet au moment du choix, quand + * celui du lecteur, lui, ne se rafraîchit qu'aux tics de position — donc plus + * du tout en pause, où l'on règle pourtant volontiers sa vitesse. */ data class PlayerUiState( val track: PlayingTrack? = null, @@ -26,6 +31,7 @@ data class PlayerUiState( val queue: List = emptyList(), val queueIndex: Int = -1, val sleepTimerActive: Boolean = false, + val playbackSpeed: Float = PlaybackSpeed.NORMALE, ) { /** Ce qui reste à jouer après le morceau courant. */ 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 96e4774..e8c0f99 100644 --- a/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import app.waveflow.WaveFlowApp +import app.waveflow.data.PreferencesStore import app.waveflow.model.RemoteSong import app.waveflow.model.Song import app.waveflow.playback.PlaybackController @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch /** * Tout ce qui touche à la lecture : état du lecteur et commandes. @@ -30,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn class PlayerViewModel( private val playbackController: PlaybackController, private val sleepTimer: SleepTimer, + private val preferencesStore: PreferencesStore, ) : ViewModel() { // Plus de croisement avec la bibliothèque : le lecteur décrit lui-même sa @@ -40,25 +43,35 @@ class PlayerViewModel( // 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 = - combine(playbackController.state, sleepTimer.endsAtMs) { playback, endsAt -> - PlayerUiState( - track = playback.current, - isPlaying = playback.isPlaying, - isBuffering = playback.isBuffering, - positionMs = playback.positionMs, - durationMs = playback.durationMs, - shuffleEnabled = playback.shuffleEnabled, - repeatMode = playback.repeatMode, - queue = playback.queue, - queueIndex = playback.queueIndex, - sleepTimerActive = endsAt != null, - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), - initialValue = PlayerUiState(), + // + // La vitesse aussi, et c'est ce qui la rend visible : elle se règle + // volontiers en pause, quand le lecteur n'émet plus rien. Prise du flux des + // préférences, elle apporte son propre battement — l'état se reconstruit au + // moment du choix. Réduite à la seule vitesse pour qu'un changement de + // thème ne traverse pas jusqu'ici. + val state: StateFlow = combine( + playbackController.state, + sleepTimer.endsAtMs, + preferencesStore.preferences.map { it.playbackSpeed }.distinctUntilChanged(), + ) { playback, endsAt, speed -> + PlayerUiState( + track = playback.current, + isPlaying = playback.isPlaying, + isBuffering = playback.isBuffering, + positionMs = playback.positionMs, + durationMs = playback.durationMs, + shuffleEnabled = playback.shuffleEnabled, + repeatMode = playback.repeatMode, + queue = playback.queue, + queueIndex = playback.queueIndex, + sleepTimerActive = endsAt != null, + playbackSpeed = speed, ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + initialValue = PlayerUiState(), + ) /** * Les pannes de lecture, à dire une fois chacune. @@ -152,10 +165,23 @@ class PlayerViewModel( fun cancelSleepTimer() = sleepTimer.cancel() /** - * Ce qu.il reste avant l.arrêt automatique, lu à l.instant de la demande. + * Change la vitesse de lecture. + * + * Écrite dans les préférences et non posée sur le lecteur : c'est le + * service qui les observe et applique, ce qui la fait tenir d'une session à + * l'autre et jusque dans les lectures démarrées sans écran ouvert. Le + * chemin de retour passe par le même flux, si bien que l'affichage suit ce + * qui a réellement été enregistré plutôt que ce qui a été demandé. + */ + fun setPlaybackSpeed(speed: Float) { + viewModelScope.launch { preferencesStore.setPlaybackSpeed(speed) } + } + + /** + * 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 + * 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() @@ -175,6 +201,7 @@ class PlayerViewModel( PlayerViewModel( playbackController = app.container.createPlaybackController(), sleepTimer = app.container.sleepTimer, + preferencesStore = app.container.preferencesStore, ) } } diff --git a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt index 280da22..3a488fc 100644 --- a/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt +++ b/app/src/test/java/app/waveflow/data/PreferencesStoreTest.kt @@ -3,8 +3,10 @@ package app.waveflow.data import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.preferencesOf import androidx.datastore.preferences.core.stringPreferencesKey +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.ThemeChoice import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -132,6 +134,135 @@ class PreferencesStoreTest { assertEquals(setOf(ThemeChoice.System), vus.toSet()) } + @Test + fun `la vitesse de lecture survit a la relecture`() = runTest { + // C'est là toute sa raison d'être persistée : qui écoute ses podcasts à + // ×1,5 ne veut pas le redire à chaque lancement. + avecUnMagasin { it.setPlaybackSpeed(1.5f) } + + val vitesse = avecUnMagasin { it.preferences.first().playbackSpeed } + + assertEquals(1.5f, vitesse, 0f) + } + + @Test + fun `sans rien de choisi la lecture est a vitesse normale`() = runTest { + val vitesse = avecUnMagasin { it.preferences.first().playbackSpeed } + + assertEquals(PlaybackSpeed.NORMALE, vitesse, 0f) + } + + @Test + fun `une vitesse aberrante ecrite dans le fichier est ramenee dans les bornes`() = runTest { + // Le cas se présente si une version future élargit les bornes puis + // qu'on redescend : l'ancienne lit une vitesse qu'elle ne sait pas + // tenir, et l'appliquer telle quelle rendrait la lecture inaudible. + // Écrit ici sans passer par `setPlaybackSpeed`, qui borne déjà : c'est + // la relecture qu'on éprouve, pas l'écriture. + val magasin = magasinFige(preferencesOf(floatPreferencesKey("playback_speed") to 8f)) + + val vitesse = magasin.preferences.first().playbackSpeed + + assertEquals(PlaybackSpeed.MAX, vitesse, 0f) + } + + @Test + fun `une vitesse aberrante n'est pas meme ecrite dans le fichier`() = runTest { + // La relecture borne déjà, et suffirait à protéger l'application. Ce + // qu'on garde ici, c'est le **fichier** : une valeur aberrante gravée + // sur le disque survivrait à une version future aux bornes plus larges, + // qui la relirait alors sans rien pour l'arrêter. + // + // Lu sous la clé brute et non par `preferences` : celui-ci borne à la + // relecture, et rendrait le test vert quoi qu'on ait écrit. + val brut = avecUnMagasinBrut { magasin, dataStore -> + magasin.setPlaybackSpeed(8f) + dataStore.data.first()[floatPreferencesKey("playback_speed")] + } + + assertEquals(PlaybackSpeed.MAX, brut!!, 0f) + } + + /** Comme [avecUnMagasin], mais donne aussi le DataStore sous-jacent. */ + private suspend fun avecUnMagasinBrut( + bloc: suspend (PreferencesStore, DataStore) -> T, + ): T { + val portee = CoroutineScope(Job() + Dispatchers.IO) + try { + val dataStore = PreferenceDataStoreFactory.create(scope = portee) { fichier } + return bloc(DataStorePreferencesStore(dataStore), dataStore) + } finally { + portee.coroutineContext[Job]!!.cancelAndJoin() + } + } + + @Test + fun `une vitesse qui ne s'ecrit pas ne fait pas tomber l'appelant`() = runTest { + // Les appelants lancent dans la portée de leur ViewModel, qui n'a pas de + // gestionnaire d'exception : sans cette retenue, un disque plein + // emporterait l'application entière — pour une vitesse de lecture. + // + // Ne lève pas : c'est tout ce qui est demandé. Le choix est perdu, et + // l'écran le dit en restant sur l'ancienne valeur. + magasinIncapableDEcrire().setPlaybackSpeed(1.5f) + } + + @Test + fun `un theme qui ne s'ecrit pas ne fait pas tomber l'appelant`() = runTest { + // Un test à part et non deux appels dans le même : la seule assertion + // est qu'il ne soit rien levé, et deux appels à la suite verraient le + // premier lever pour les deux. La seconde écriture n'aurait alors + // jamais été éprouvée. + magasinIncapableDEcrire().setTheme(ThemeChoice.Dark) + } + + /** Un magasin dont toute écriture échoue, la lecture restant possible. */ + private fun magasinIncapableDEcrire(): PreferencesStore = + DataStorePreferencesStore( + object : DataStore { + override val data: Flow = flow { emit(preferencesOf()) } + override suspend fun updateData( + transform: suspend (Preferences) -> Preferences, + ): Preferences = throw IOException("disque plein") + }, + ) + + @Test + fun `une vitesse d'un autre type que le sien ne fait pas tomber la collecte`() = runTest { + // Le fichier peut porter une clé au type qu'on n'attend pas : une + // version future qui l'aurait changée, puis un retour en arrière. La + // lecture par `this[cle]` est un cast non vérifié, et ce qu'elle lève + // passerait **après** le `catch`, posé en amont de la conversion. + val magasin = magasinFige(preferencesOf(stringPreferencesKey("playback_speed") to "vite")) + + val prefs = magasin.preferences.first() + + assertEquals(PlaybackSpeed.NORMALE, prefs.playbackSpeed, 0f) + } + + @Test + fun `un theme d'un autre type que le sien ne fait pas tomber la collecte`() = runTest { + // Un test à part : les deux lectures sont distinctes, et réunies ici la + // première lèverait pour les deux — la seconde n'aurait jamais été + // éprouvée. Ce trou-là précède la vitesse de lecture. + val magasin = magasinFige(preferencesOf(floatPreferencesKey("theme") to 2f)) + + val prefs = magasin.preferences.first() + + assertEquals(ThemeChoice.System, prefs.theme) + } + + /** Un magasin en lecture seule, sur un contenu écrit à la main. */ + private fun magasinFige(contenu: Preferences): PreferencesStore = + DataStorePreferencesStore( + object : DataStore { + override val data: Flow = flow { emit(contenu) } + override suspend fun updateData( + transform: suspend (Preferences) -> Preferences, + ): Preferences = throw UnsupportedOperationException("lecture seule") + }, + ) + @Test fun `changer de theme se voit sans rouvrir le fichier`() = runTest { // Un flux, et non une lecture ponctuelle : l'écran qui change le thème diff --git a/app/src/test/java/app/waveflow/model/PlaybackSpeedTest.kt b/app/src/test/java/app/waveflow/model/PlaybackSpeedTest.kt new file mode 100644 index 0000000..06dc282 --- /dev/null +++ b/app/src/test/java/app/waveflow/model/PlaybackSpeedTest.kt @@ -0,0 +1,68 @@ +package app.waveflow.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** La vitesse telle qu'elle se dit, et telle qu'on la garde présentable. */ +class PlaybackSpeedTest { + + @Test + fun `les zeros inutiles ne s'ecrivent pas`() { + // « ×1,00 » suggérerait une précision au centième que le réglage + // n'offre pas, et allongerait un bouton qui doit tenir dans 48 dp. + assertEquals("×1", PlaybackSpeed.format(1f)) + assertEquals("×2", PlaybackSpeed.format(2f)) + assertEquals("×1,5", PlaybackSpeed.format(1.5f)) + } + + @Test + fun `le separateur decimal est la virgule, en toute locale`() { + // `String.format` suivrait la locale de l'appareil : « ×1.5 » sur un + // téléphone en anglais, au milieu d'une interface en français. + val defaut = java.util.Locale.getDefault() + try { + java.util.Locale.setDefault(java.util.Locale.US) + assertEquals("×1,25", PlaybackSpeed.format(1.25f)) + } finally { + java.util.Locale.setDefault(defaut) + } + } + + @Test + fun `un centieme non nul garde son zero de tete`() { + // Sans lui, ×1,05 s'écrirait « ×1,5 » — quatre fois plus rapide que + // demandé, et rigoureusement impossible à repérer à l'écran. + assertEquals("×1,05", PlaybackSpeed.format(1.05f)) + } + + @Test + fun `toutes les vitesses proposees s'ecrivent`() { + // Le bouton les affiche telles quelles : aucune ne doit rendre une + // forme inattendue, et toutes tiennent en cinq caractères. + assertEquals( + listOf("×0,5", "×0,75", "×1", "×1,25", "×1,5", "×1,75", "×2"), + PlaybackSpeed.PROPOSEES.map(PlaybackSpeed::format), + ) + } + + @Test + fun `une vitesse hors bornes est ramenee dedans`() { + // La valeur vient du disque : une version future aux bornes plus larges, + // ou un fichier abîmé. Une vitesse nulle figerait la lecture sans rien + // dire de ce qui se passe. + assertEquals(PlaybackSpeed.MAX, PlaybackSpeed.borner(8f), 0f) + assertEquals(PlaybackSpeed.MIN, PlaybackSpeed.borner(0.1f), 0f) + assertEquals(PlaybackSpeed.MIN, PlaybackSpeed.borner(0f), 0f) + assertEquals(PlaybackSpeed.MIN, PlaybackSpeed.borner(-1f), 0f) + } + + @Test + fun `ce qui n'est pas un nombre vaut la vitesse normale`() { + // `coerceIn` laisse passer NaN tel quel, et Media3 lève en le recevant + // — loin de l'endroit où il a été lu, donc difficile à rattacher au + // fichier de préférences qui l'a produit. + assertEquals(PlaybackSpeed.NORMALE, PlaybackSpeed.borner(Float.NaN), 0f) + assertEquals(PlaybackSpeed.NORMALE, PlaybackSpeed.borner(Float.POSITIVE_INFINITY), 0f) + assertEquals(PlaybackSpeed.NORMALE, PlaybackSpeed.borner(Float.NEGATIVE_INFINITY), 0f) + } +} diff --git a/app/src/test/java/app/waveflow/playback/PlaybackServiceSpeedTest.kt b/app/src/test/java/app/waveflow/playback/PlaybackServiceSpeedTest.kt new file mode 100644 index 0000000..b98c41f --- /dev/null +++ b/app/src/test/java/app/waveflow/playback/PlaybackServiceSpeedTest.kt @@ -0,0 +1,120 @@ +package app.waveflow.playback + +import android.app.Application +import android.os.Bundle +import android.os.Looper +import androidx.media3.session.MediaSession +import androidx.test.core.app.ApplicationProvider +import app.waveflow.WaveFlowApp +import app.waveflow.data.PreferencesStore +import app.waveflow.model.PlaybackSpeed +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController +import java.util.concurrent.TimeUnit + +/** + * La vitesse de lecture, du fichier de préférences jusqu'au lecteur. + * + * C'est le maillon que rien d'autre ne couvre. `PreferencesStoreTest` prouve + * que la vitesse survit au redémarrage, `PlayerViewModelTest` qu'elle remonte à + * l'écran ; entre les deux, il reste à montrer que **quelqu'un l'applique** — et + * ce quelqu'un est le service, précisément parce qu'il joue aussi quand aucun + * écran n'est ouvert. + * + * Le vrai magasin de l'application, et non un faux : le service va le chercher + * lui-même dans le conteneur, il ne reçoit aucune dépendance en paramètre. La + * vitesse est donc remise à l'ordinaire après chaque test — le délégué + * `preferencesDataStore` mémorise son instance pour toute la machine virtuelle, + * et ce qu'on laisse derrière soi, un autre test le relira. + */ +@RunWith(RobolectricTestRunner::class) +class PlaybackServiceSpeedTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + private val preferences: PreferencesStore get() = (app as WaveFlowApp).container.preferencesStore + + private var service: ServiceController? = null + + @After + fun tearDown() { + service?.destroy() + runBlocking { preferences.setPlaybackSpeed(PlaybackSpeed.NORMALE) } + } + + @Test + fun `la vitesse enregistree est appliquee des le demarrage du service`() { + // Le cas qui motive tout le montage : on règle ×1,5, on ferme + // l'application, puis la lecture repart depuis Android Auto ou la + // notification. Si personne n'applique la préférence à ce moment-là, + // elle retombe à ×1 exactement là où on ne peut pas la corriger. + runBlocking { preferences.setPlaybackSpeed(1.5f) } + + val lecteur = lecteurDuService() + + assertEquals(1.5f, lecteur.attendreVitesse(1.5f), 0f) + } + + @Test + fun `changer la vitesse pendant la lecture la porte jusqu'au lecteur`() { + // Le service observe, il ne lit pas une fois pour toutes : sans cela le + // réglage n'aurait d'effet qu'au redémarrage suivant. + val lecteur = lecteurDuService() + lecteur.attendreVitesse(PlaybackSpeed.NORMALE) + + runBlocking { preferences.setPlaybackSpeed(0.75f) } + + assertEquals(0.75f, lecteur.attendreVitesse(0.75f), 0f) + } + + /** Le lecteur que le service tient, tel qu'Android le lui a fait construire. */ + private fun lecteurDuService(): androidx.media3.common.Player { + val controller = Robolectric.buildService(PlaybackService::class.java).create() + service = controller + return controller.get().onGetSession(appelant())!!.player + } + + /** + * Attend que la vitesse devienne [attendue], puis la rend. + * + * L'observation traverse deux fils : le DataStore lit sur son propre + * dispatcher, et la pose sur le lecteur revient au fil principal, que + * Robolectric n'anime que si on le lui demande. Sans cette attente, le test + * lirait la vitesse avant que la collecte ait eu lieu — et passerait ou + * échouerait selon l'humeur de la machine. + * + * Bornée : une vitesse qui n'arriverait plus doit faire échouer le test, pas + * le faire attendre indéfiniment. L'assertion de l'appelant tranche ensuite, + * y compris quand l'échéance est atteinte. + */ + private fun androidx.media3.common.Player.attendreVitesse(attendue: Float): Float { + val echeance = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_S) + while (playbackParameters.speed != attendue && System.nanoTime() < echeance) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(5) + } + return playbackParameters.speed + } + + private fun appelant(): MediaSession.ControllerInfo = + MediaSession.ControllerInfo.createTestOnlyControllerInfo( + /* packageName = */ "app.waveflow", + /* pid = */ 0, + /* uid = */ 0, + /* libraryVersion = */ 0, + /* interfaceVersion = */ 0, + /* trusted = */ true, + /* connectionHints = */ Bundle.EMPTY, + /* isPackageNameVerified = */ true, + ) + + private companion object { + const val TIMEOUT_S = 5L + } +} diff --git a/app/src/test/java/app/waveflow/testing/Fakes.kt b/app/src/test/java/app/waveflow/testing/Fakes.kt index 00e2bae..0b09fcf 100644 --- a/app/src/test/java/app/waveflow/testing/Fakes.kt +++ b/app/src/test/java/app/waveflow/testing/Fakes.kt @@ -3,10 +3,14 @@ package app.waveflow.testing import android.net.Uri import app.waveflow.data.MusicRepository import app.waveflow.data.PlaylistRepository +import app.waveflow.data.PreferencesStore +import app.waveflow.model.AppPreferences +import app.waveflow.model.PlaybackSpeed import app.waveflow.model.Playlist import app.waveflow.model.PlaylistEntry import app.waveflow.model.RemoteSong import app.waveflow.model.Song +import app.waveflow.model.ThemeChoice import app.waveflow.playback.PlaybackController import app.waveflow.playback.PlaybackState import app.waveflow.playback.PlayingTrack @@ -16,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update /** * Fabrique de morceaux pour les tests. @@ -211,3 +216,36 @@ class FakePlaybackController : PlaybackController { _state.value = state } } + +/** + * Les préférences, en mémoire. + * + * Un `MutableStateFlow` et non le vrai magasin : ce que ces tests éprouvent est + * ce que fait l'application d'une préférence qui change, pas la fidélité du + * DataStore — celle-ci se joue dans `PreferencesStoreTest`, sur un vrai fichier. + * + * Il **borne comme le vrai**. Un faux plus permissif que l'original rendrait + * verts des tests qui décriraient une application qui n'existe pas. + */ +class FakePreferencesStore(initial: AppPreferences = AppPreferences()) : PreferencesStore { + + // Borné dès la construction, et pas seulement à l'écriture : le vrai + // magasin borne ce qu'il relit, et un faux plus permissif rendrait vert un + // test décrivant une application qui n'existe pas. + private val flux = MutableStateFlow( + initial.copy(playbackSpeed = PlaybackSpeed.borner(initial.playbackSpeed)), + ) + + override val preferences: Flow = flux + + override suspend fun setTheme(choice: ThemeChoice) { + flux.update { it.copy(theme = choice) } + } + + override suspend fun setPlaybackSpeed(speed: Float) { + flux.update { it.copy(playbackSpeed = PlaybackSpeed.borner(speed)) } + } + + val theme: ThemeChoice get() = flux.value.theme + val playbackSpeed: Float get() = flux.value.playbackSpeed +} diff --git a/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt b/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt new file mode 100644 index 0000000..0188443 --- /dev/null +++ b/app/src/test/java/app/waveflow/ui/player/PlaybackSpeedSheetTest.kt @@ -0,0 +1,67 @@ +package app.waveflow.ui.player + +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Ce que la feuille de vitesse montre, et ce qu'elle rend en retour. */ +@RunWith(RobolectricTestRunner::class) +class PlaybackSpeedSheetTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun `la vitesse en vigueur est celle qui apparait selectionnee`() { + // Sans elle, la feuille serait une liste de sept lignes identiques : + // impossible d'y lire ce qu'on écoute, donc impossible de savoir de + // combien on s'en écarte en choisissant. + compose.setContent { + PlaybackSpeedSheet(speed = 1.5f, onPick = {}, onDismiss = {}) + } + + compose.onNodeWithText("×1,5").assertIsSelected() + } + + @Test + fun `choisir une vitesse la remonte telle quelle`() { + var choisie: Float? = null + compose.setContent { + PlaybackSpeedSheet(speed = 1f, onPick = { choisie = it }, onDismiss = {}) + } + + compose.onNodeWithText("×1,75").performClick() + + assertEquals(1.75f, choisie!!, 0f) + } + + @Test + fun `une vitesse absente des propositions apparait quand meme`() { + // Le format persisté ne se limite pas aux sept propositions : une + // version future peut offrir un réglage plus fin. La feuille s'ouvrirait + // alors sans rien de coché, à contredire le bouton qui l'a ouverte. + compose.setContent { + PlaybackSpeedSheet(speed = 1.1f, onPick = {}, onDismiss = {}) + } + + compose.onNodeWithText("×1,1").assertIsSelected() + } + + @Test + fun `la vitesse normale se nomme, pour qu'on sache ou revenir`() { + // « ×1 » seul, au milieu de six autres nombres, ne signale pas qu'il est + // le point de départ — et c'est celui qu'on cherche pour tout annuler. + compose.setContent { + PlaybackSpeedSheet(speed = 1.5f, onPick = {}, onDismiss = {}) + } + + compose.onNodeWithText("×1 (normale)").assertIsNotSelected() + } +} 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 cb946d8..e8d4879 100644 --- a/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/player/PlayerViewModelTest.kt @@ -4,12 +4,16 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import app.waveflow.data.PreferencesStore +import app.waveflow.model.AppPreferences +import app.waveflow.model.PlaybackSpeed 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.FakePreferencesStore import app.waveflow.testing.MainDispatcherRule import app.waveflow.testing.remoteSong import app.waveflow.testing.song @@ -39,6 +43,7 @@ class PlayerViewModelTest { private val songs = listOf(song(id = 1L), song(id = 2L), song(id = 3L)) private val remoteSongs = listOf(remoteSong("a"), remoteSong("b"), remoteSong("c")) private val controller = FakePlaybackController() + private val preferences = FakePreferencesStore() /** * Le ViewModel sous test, muni d'une minuterie qui suit l'horloge virtuelle. @@ -47,9 +52,12 @@ class PlayerViewModelTest { * minuterie qui survivrait d'un test à l'autre porterait son échéance avec * elle. */ - private fun TestScope.playerViewModel() = PlayerViewModel( + private fun TestScope.playerViewModel( + preferencesStore: PreferencesStore = preferences, + ) = PlayerViewModel( playbackController = controller, sleepTimer = SleepTimer(backgroundScope) { testScheduler.currentTime }, + preferencesStore = preferencesStore, ) @Test @@ -307,4 +315,52 @@ class PlayerViewModelTest { job.cancel() } + + @Test + fun `la vitesse enregistree habille l'etat des le depart`() = runTest { + // Sans cette remontée, l'écran s'ouvrirait sur « ×1 » pendant que le + // service joue déjà à ×1,5 : le bouton mentirait sur ce qu'on entend. + val viewModel = playerViewModel( + FakePreferencesStore(AppPreferences(playbackSpeed = 1.5f)), + ) + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + assertEquals(1.5f, viewModel.state.value.playbackSpeed, 0f) + + job.cancel() + } + + @Test + fun `changer la vitesse se voit alors que le lecteur n'emet plus rien`() = runTest { + // Le piège du lecteur : `PlaybackState` ne se reconstruit qu'aux tics de + // position, donc plus du tout en pause — et c'est justement en pause + // qu'on règle sa vitesse. Le contrôleur reste ici muet de bout en bout : + // si l'état tenait la vitesse de lui, rien ne bougerait. + val viewModel = playerViewModel() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.state.collect {} } + advanceUntilIdle() + + assertEquals(PlaybackSpeed.NORMALE, viewModel.state.value.playbackSpeed, 0f) + + viewModel.setPlaybackSpeed(1.5f) + advanceUntilIdle() + + assertEquals(1.5f, viewModel.state.value.playbackSpeed, 0f) + + job.cancel() + } + + @Test + fun `la vitesse choisie passe par les preferences et non par le lecteur`() = runTest { + // C'est ce qui la fait tenir d'un lancement à l'autre, et s'appliquer + // aux lectures démarrées sans qu'aucun écran soit ouvert : le service + // observe la préférence, il n'attend pas d'ordre de l'interface. + val viewModel = playerViewModel() + + viewModel.setPlaybackSpeed(1.75f) + advanceUntilIdle() + + assertEquals(1.75f, preferences.playbackSpeed, 0f) + } } diff --git a/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt index 926f03c..c7b3b70 100644 --- a/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/app/waveflow/ui/settings/SettingsViewModelTest.kt @@ -1,13 +1,10 @@ package app.waveflow.ui.settings -import app.waveflow.data.PreferencesStore import app.waveflow.model.AppPreferences import app.waveflow.model.ThemeChoice +import app.waveflow.testing.FakePreferencesStore import app.waveflow.testing.MainDispatcherRule import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -27,16 +24,6 @@ class SettingsViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private class FakePreferencesStore(initial: AppPreferences) : PreferencesStore { - private val flux = MutableStateFlow(initial) - override val preferences: Flow = flux - override suspend fun setTheme(choice: ThemeChoice) { - flux.update { it.copy(theme = choice) } - } - - val theme: ThemeChoice get() = flux.value.theme - } - @Test fun `le theme enregistre est pret sans que personne n'ait collecte`() = runTest(mainDispatcherRule.dispatcher) { diff --git a/docs/PASSATION.md b/docs/PASSATION.md index a2b2b19..bc81cf3 100644 --- a/docs/PASSATION.md +++ b/docs/PASSATION.md @@ -4,12 +4,13 @@ Document vivant : chaque agent qui prend la suite le relit d'abord, et le met à jour avant de partir. Il dit **où en est le chantier et ce qui vient ensuite** — pas l'historique, que `git log` raconte mieux. -Dernière mise à jour : **2026-09-06**, sur `main` = `1c203f6`. +Dernière mise à jour : **2026-09-06**, sur `main` = `59db1fd`. ## État du dépôt -- `main` = `1c203f6`, arbre propre, **aucune PR ouverte**, aucune branche en cours. -- **346 tests verts**, CI verte (workflow `Build & test`, ~4 min 45 s). +- `main` = `59db1fd`. Une PR ouverte : **la vitesse de lecture**, branche + `feat/vitesse-lecture`. +- **364 tests verts**, CI verte (workflow `Build & test`, ~4 min 45 s). - **Aucun avertissement de compilation.** C'est une propriété qu'on tient, pas un hasard — voir le piège `textReport` plus bas avant d'en supprimer un. - Gradle 9.7.1, AGP 9.3.2, OkHttp 5.5.0, media3 1.11.0. @@ -21,7 +22,8 @@ Six lots, dans cet ordre : 1. Réglages / DataStore — **fait** 2. Navigation + identité — **fait** -3. Lecteur : file d'attente, minuterie, vitesse, boucle A-B — **aux deux tiers** +3. Lecteur : file d'attente, minuterie, vitesse, boucle A-B — **il ne reste que + la boucle A-B** 4. Transcodage (remonté du 6ᵉ rang : meilleur rapport travail/effet, le serveur est déjà prêt) 5. Paroles @@ -32,6 +34,30 @@ lecteurs ou une chaîne audio maison. ## Ce que la dernière session a livré +**La vitesse de lecture** (branche `feat/vitesse-lecture`). + +Elle vit dans les **préférences**, et c'est le **service** qui les observe pour +l'appliquer au lecteur — comme il écoute déjà les expirations de la minuterie. +Ni l'écran ni le `PlaybackController` n'y touchent. Deux raisons, et la seconde +est la vraie : la vitesse se persiste, et une lecture démarrée sans écran ouvert +— Android Auto, la notification — doit partir à la bonne vitesse. Un réglage +posé par l'interface retomberait à ×1 précisément là où on ne peut pas le +corriger. + +L'affichage suit le même chemin : `PlayerUiState.playbackSpeed` vient du flux +des préférences, non de `PlaybackState`. C'est ce qui **désamorce le piège n° 1** +plutôt que de le contourner — le flux des préférences émet au moment du choix, y +compris en pause, où l'on règle justement sa vitesse. Le bouton de l'en-tête +*est* son affichage : il porte le chiffre, pas une icône. + +Le bornage est double, à l'écriture **et** à la relecture. Le second protège +l'application d'un fichier écrit par une version future aux bornes plus larges ; +le premier protège le fichier lui-même. Ils s'éprouvent séparément — voir plus +bas. + +**Ce qui n'a pas été fait :** le mini-player ne dit pas la vitesse. Elle n'est +visible qu'une fois le lecteur déplié. À revoir si quelqu'un s'y perd. + **PR #47 — la minuterie de veille.** `SleepTimer` vit dans `AppContainer`, pas dans le service : on règle une @@ -52,10 +78,16 @@ traiter à part. ### 1. `PlayerUiState` ne se reconstruit qu'aux tics de position **Donc plus du tout en pause.** La minuterie s'y est fait prendre : le décompte -était câblé sur l'état figé et ne bougeait plus. La vitesse de lecture et les -bornes A-B liront le même état — il faudra soit les publier sur un flux propre, -soit les relire à l'affichage, comme `SleepTimerSheet` le fait avec un tic local -qui ne vit que le temps où la feuille est ouverte. +était câblé sur l'état figé et ne bougeait plus. Les bornes A-B liront le même +état — il faudra soit les publier sur un flux propre, soit les relire à +l'affichage, comme `SleepTimerSheet` le fait avec un tic local qui ne vit que le +temps où la feuille est ouverte. + +**La vitesse ne s'y est pas laissé prendre**, et c'est la voie à reprendre : elle +ne passe pas par `PlaybackState` du tout, mais par le flux des préférences, qui +émet au moment du choix. Le piège se désamorce mieux qu'il ne se contourne — si +quelque chose d'autre que le lecteur peut porter les bornes A-B, qu'il les +porte. ### 2. « Vérifier puis agir » : la course qui revient @@ -93,22 +125,10 @@ une erreur. Ne pas rouvrir. ## La suite : solder le lot 3 -Rien n'existe encore — ni `setPlaybackSpeed`, ni `PlaybackParameters`, aucune -trace d'A-B dans `PlaybackController`. +La vitesse est faite. Reste la boucle A-B — aucune trace dans +`PlaybackController`. -### a. La vitesse de lecture — à faire en premier - -La plus simple, et de loin. Media3 la fournit d'un appel ; elle se pose dans -`PlaybackController` comme les autres commandes. - -Deux points de conception : - -- **Elle se persiste.** On ne veut pas retomber à ×1 à chaque relance quand on - écoute un podcast à ×1,5. Le `PreferencesStore` est là pour ça. -- **Elle s'affiche.** Une vitesse active et invisible est un défaut qu'on cherche - pendant vingt minutes. Voir le piège n° 1 pour la publier correctement. - -### b. La boucle A-B — ensuite, nettement plus retorse +### La boucle A-B — tout ce qui reste, et le plus retorse Media3 **n'a pas** de « répéter entre deux points ». Il faut échantillonner la position et rembobiner au passage de B, ce qui place le mécanisme **dans le @@ -116,6 +136,11 @@ service**, pas dans l'interface. Ce que Robolectric peut en prouver est déjà borné : `isPlaying` à `true` et l'échantillonnage de position restent hors de portée faute de codec. +**L'en-tête du lecteur est plein.** Quatre boutons y tiennent déjà — réduire, +vitesse, veille, file — et la colonne du titre s'en trouve serrée sur un écran +étroit. L'A-B n'y entrera pas sans un menu de débordement qui regrouperait +veille, vitesse et bornes. C'est le moment de le poser, pas après. + ## En attente d'une décision de l'utilisateur Ni l'un ni l'autre n'est bloquant. Ils ne sont pas oubliés, ils sont posés. @@ -155,6 +180,9 @@ pagine sur le réseau. Le retirer de la barre sans cela l'aurait rendu - **Tout test de régression se valide par retrait** : on enlève le correctif et on vérifie que le bon test — et lui seul — tombe, avec `--rerun-tasks`. Un test qui passe des deux côtés est un test creux, et il y en a six formes connues. + *Sur une machine à court de mémoire, `--rerun-tasks` fait tomber le build ; + un retrait modifie de toute façon une source, ce qui invalide déjà la tâche de + test. Le drapeau ne protège que du cas où rien n'a changé.* - `./gradlew ktlintFormat` **avant chaque commit**. - **Français** pour l'interface, les messages de commit, la KDoc, les commentaires et les noms de tests (`build-and-test.yml` excepté).