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
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 @@ -647,6 +647,9 @@ private fun WaveFlowRoot(
onSeek = playerViewModel::seekTo,
onToggleShuffle = playerViewModel::toggleShuffle,
onCycleRepeat = playerViewModel::cycleRepeatMode,
onPlayQueueItem = playerViewModel::playQueueItem,
onMoveQueueItem = playerViewModel::moveQueueItem,
onRemoveQueueItem = playerViewModel::removeQueueItem,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ class Media3PlaybackController(
private val playerListener = object : Player.Listener {
// Un seul point de synchronisation plutôt qu'un callback par champ :
// on relit l'état complet du lecteur à chaque salve d'événements.
override fun onEvents(player: Player, events: Player.Events) = syncFrom(player)
override fun onEvents(player: Player, events: Player.Events) = syncFrom(
player = player,
// La file ne bouge qu'avec la timeline. Passer d'un morceau au
// suivant la relirait sinon en entier, à chaque piste, pour rendre
// exactement la même liste.
refreshQueue = events.contains(Player.EVENT_TIMELINE_CHANGED),
)
}

override fun connect() {
Expand Down Expand Up @@ -149,6 +155,34 @@ class Media3PlaybackController(
ctrl.shuffleModeEnabled = !ctrl.shuffleModeEnabled
}

override fun playQueueItem(index: Int) {
val ctrl = controller ?: return
if (index !in 0 until ctrl.mediaItemCount) return
ctrl.seekToDefaultPosition(index)
ctrl.play()
}

/**
* Les rangs viennent d'une liste que l'écran affichait : entre son rendu et
* le geste, la file a pu se vider ou raccourcir.
*
* Ces gardes sont défensives et **non éprouvées** : le `MediaController`
* d'aujourd'hui borne déjà les rangs qu'on lui passe, si bien que les
* retirer ne fait tomber aucun test. C'est l'interface `Player` qu'elles
* regardent, et celle-ci ne promet rien de tel.
*/
override fun moveQueueItem(from: Int, to: Int) {
val ctrl = controller ?: return
if (from !in 0 until ctrl.mediaItemCount || to !in 0 until ctrl.mediaItemCount) return
ctrl.moveMediaItem(from, to)
}

override fun removeQueueItem(index: Int) {
val ctrl = controller ?: return
if (index !in 0 until ctrl.mediaItemCount) return
ctrl.removeMediaItem(index)
}

override fun cycleRepeatMode() {
val ctrl = controller ?: return
ctrl.repeatMode = when (ctrl.repeatMode) {
Expand All @@ -167,7 +201,14 @@ class Media3PlaybackController(
_state.value = PlaybackState()
}

private fun syncFrom(player: Player) {
/**
* @param refreshQueue relire la file entière, ce qui coûte un parcours de
* la timeline. Inutile quand seul le morceau courant a changé — le rang
* suffit alors, et il se lit en temps constant.
*/
private fun syncFrom(player: Player, refreshQueue: Boolean = true) {
val queue = if (refreshQueue) player.readQueue() else _state.value.queue

_state.value = PlaybackState(
isConnected = true,
current = player.currentMediaItem?.toPlayingTrack(),
Expand All @@ -182,6 +223,8 @@ class Media3PlaybackController(
else -> RepeatMode.Off
},
failure = player.playerError?.toPlaybackFailure(),
queue = queue,
queueIndex = player.currentMediaItemIndex.takeIf { queue.isNotEmpty() } ?: -1,
)

if (player.isPlaying) startPositionUpdates() else stopPositionUpdates()
Expand All @@ -205,6 +248,16 @@ class Media3PlaybackController(
positionJob = null
}

/**
* La file dans l'ordre où elle a été posée.
*
* L'ordre de la timeline, et non celui du parcours aléatoire : c'est la
* liste que l'utilisateur a constituée, et celle qu'il retrouve en coupant
* la lecture aléatoire.
*/
private fun Player.readQueue(): List<PlayingTrack> =
List(mediaItemCount) { getMediaItemAt(it).toPlayingTrack() }

private companion object {
const val POSITION_POLL_MS = 500L
}
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/app/waveflow/playback/PlaybackController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ enum class PlaybackFailure {
* @property failure panne en cours, `null` tant que le lecteur va bien. Elle
* s'efface d'elle-même à la reprise : Media3 oublie son erreur dès qu'on le
* prépare à nouveau.
* @property queue la file telle qu'elle a été posée, dans son ordre d'origine.
* La lecture aléatoire ne la réordonne pas : elle change l'ordre de parcours,
* pas la liste — c'est pourquoi la désactiver rend la suite intacte.
* @property queueIndex rang du morceau courant dans [queue], `-1` si la file
* est vide.
*/
data class PlaybackState(
val isConnected: Boolean = false,
Expand All @@ -60,6 +65,8 @@ data class PlaybackState(
val shuffleEnabled: Boolean = false,
val repeatMode: RepeatMode = RepeatMode.Off,
val failure: PlaybackFailure? = null,
val queue: List<PlayingTrack> = emptyList(),
val queueIndex: Int = -1,
)

/**
Expand Down Expand Up @@ -110,6 +117,26 @@ interface PlaybackController {
/** Fait tourner le mode de répétition : Off -> All -> One -> Off. */
fun cycleRepeatMode()

/** Saute au rang [index] de la file et joue. */
fun playQueueItem(index: Int)

/**
* Déplace un morceau dans la file.
*
* Déplacer celui qui joue ne l'interrompt pas : Media3 suit la piste
* courante à travers le remaniement, et c'est ce qu'on attend en tirant une
* ligne pendant qu'elle sonne.
*/
fun moveQueueItem(from: Int, to: Int)

/**
* Retire un morceau de la file.
*
* Retirer celui qui joue enchaîne sur le suivant plutôt que d'arrêter tout :
* l'utilisateur a écarté un morceau, pas demandé le silence.
*/
fun removeQueueItem(index: Int)

/** Libère le contrôleur ; le service, lui, continue de jouer. */
fun release()
}
81 changes: 66 additions & 15 deletions app/src/main/java/app/waveflow/ui/player/NowPlayingScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
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.KeyboardArrowDown
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
Expand All @@ -39,6 +40,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -74,8 +76,15 @@ fun NowPlayingScreen(
onSeek: (Long) -> Unit,
onToggleShuffle: () -> Unit,
onCycleRepeat: () -> Unit,
onPlayQueueItem: (Int) -> Unit,
onMoveQueueItem: (from: Int, to: Int) -> Unit,
onRemoveQueueItem: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
// Local et non remonté : voir la file est une façon de regarder le lecteur,
// pas un état de l'application. Refermer le lecteur la referme.
var queueShown by rememberSaveable { mutableStateOf(false) }

// 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 All @@ -101,20 +110,39 @@ fun NowPlayingScreen(
.windowInsetsPadding(WindowInsets.systemBars)
.padding(horizontal = 24.dp),
) {
PlayerHeader(track = track, onCollapse = onCollapse)
PlayerHeader(
track = track,
onCollapse = onCollapse,
queueShown = queueShown,
upNextCount = state.upNextCount,
onToggleQueue = { queueShown = !queueShown },
)

Spacer(Modifier.weight(1f))
if (queueShown) {
// La file prend la place de la pochette et non celle de tout
// l'écran : on garde sous les yeux ce qui joue et de quoi
// l'arrêter pendant qu'on remanie la suite.
QueueScreen(
state = state,
onPlayAt = onPlayQueueItem,
onMove = onMoveQueueItem,
onRemove = onRemoveQueueItem,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))

Artwork(
artworkUri = track.artworkUri,
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp)),
)
Artwork(
artworkUri = track.artworkUri,
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp)),
)

Spacer(Modifier.weight(1f))
Spacer(Modifier.weight(1f))
}

TrackTitle(track = track)

Expand Down Expand Up @@ -147,7 +175,13 @@ fun NowPlayingScreen(
}

@Composable
private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) {
private fun PlayerHeader(
track: PlayingTrack,
onCollapse: () -> Unit,
queueShown: Boolean,
upNextCount: Int,
onToggleQueue: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
Expand All @@ -164,7 +198,7 @@ private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "EN LECTURE",
text = if (queueShown) "FILE D'ATTENTE" else "EN LECTURE",
style = MaterialTheme.typography.labelSmall,
letterSpacing = 1.5.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
Expand All @@ -178,8 +212,25 @@ private fun PlayerHeader(track: PlayingTrack, onCollapse: () -> Unit) {
)
}

// Contrepoids du bouton de gauche pour garder le titre centré.
Spacer(Modifier.size(48.dp))
// Le contrepoids du bouton de gauche devient utile : il ouvre la file.
IconButton(onClick = onToggleQueue) {
Icon(
imageVector = Icons.AutoMirrored.Filled.QueueMusic,
contentDescription = if (queueShown) {
"Revenir à la pochette"
} else {
// Le nombre est dans la description plutôt qu'affiché : au
// volant comme au lecteur d'écran, « trois morceaux
// ensuite » vaut mieux qu'une pastille.
"Voir la file — $upNextCount ${if (upNextCount > 1) "morceaux" else "morceau"} ensuite"
},
tint = if (queueShown) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/app/waveflow/ui/player/PlayerUiState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ data class PlayerUiState(
val durationMs: Long = 0L,
val shuffleEnabled: Boolean = false,
val repeatMode: RepeatMode = RepeatMode.Off,
val queue: List<PlayingTrack> = emptyList(),
val queueIndex: Int = -1,
) {
/** Ce qui reste à jouer après le morceau courant. */
val upNextCount: Int
get() = (queue.size - queueIndex - 1).coerceAtLeast(0)

/** Avancement dans le morceau, entre 0 et 1 (0 si la durée est inconnue). */
val progress: Float
get() = if (durationMs > 0L) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/app/waveflow/ui/player/PlayerViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class PlayerViewModel(
durationMs = playback.durationMs,
shuffleEnabled = playback.shuffleEnabled,
repeatMode = playback.repeatMode,
queue = playback.queue,
queueIndex = playback.queueIndex,
)
}.stateIn(
scope = viewModelScope,
Expand Down Expand Up @@ -129,6 +131,12 @@ class PlayerViewModel(

fun cycleRepeatMode() = playbackController.cycleRepeatMode()

fun playQueueItem(index: Int) = playbackController.playQueueItem(index)

fun moveQueueItem(from: Int, to: Int) = playbackController.moveQueueItem(from, to)

fun removeQueueItem(index: Int) = playbackController.removeQueueItem(index)

override fun onCleared() {
// Le service, lui, survit et continue la lecture en arrière-plan.
playbackController.release()
Expand Down
Loading