feat: sleep timer, playback speed, real queue with shuffle/repeat

- Sleep timer: 15/30/45/60/90 min, live countdown chip, pauses
  playback on expiry (station stays loaded); cancel from dialog
- Playback speed 0.5x-2x for stations and saved songs; persists
  across track changes; chip shown when != 1x
- Local playback now uses an ExoPlayer media queue (library list from
  tapped song, shuffle option): native advance at track end, transport
  next/prev walk the queue, repeat off/all/one
- Player transport routes queue-first: local playback advances the
  queue, radio cycles stations; repeat/shuffle dimmed for radio
- Library 'now playing' highlight follows queue transitions via
  currentLocalTrackIdFlow; dead manual shuffle-index code removed
- Verified on-device: speed 1.25x confirmed in media_session, sleep
  dialog + countdown, save-from-air picker, radio option states
This commit is contained in:
avi 2026-09-08 01:30:43 -05:00
commit ec55ba84ae
4 changed files with 440 additions and 70 deletions

View file

@ -90,6 +90,72 @@ class PlaybackManager(
private val _lastStreamMetaFlow = MutableStateFlow<StreamMetadata?>(null)
val lastStreamMetaFlow: StateFlow<StreamMetadata?> = _lastStreamMetaFlow.asStateFlow()
// ── Queue / repeat / shuffle / speed / sleep timer ─────────────────
/** Playback speed, 0.5×–2.0×. Survives station and track changes. */
private val _speedFlow = MutableStateFlow(1.0f)
val speedFlow: StateFlow<Float> = _speedFlow.asStateFlow()
private val _repeatModeFlow = MutableStateFlow(androidx.media3.common.Player.REPEAT_MODE_OFF)
val repeatModeFlow: StateFlow<Int> = _repeatModeFlow.asStateFlow()
private val _shuffleEnabledFlow = MutableStateFlow(false)
val shuffleEnabledFlow: StateFlow<Boolean> = _shuffleEnabledFlow.asStateFlow()
/** Milliseconds left on the sleep timer; 0 when off. */
private val _sleepRemainingMsFlow = MutableStateFlow(0L)
val sleepRemainingMsFlow: StateFlow<Long> = _sleepRemainingMsFlow.asStateFlow()
/** Library song id of the current queue item (null when not queue-playing). */
private val _currentLocalTrackIdFlow = MutableStateFlow<Long?>(null)
val currentLocalTrackIdFlow: StateFlow<Long?> = _currentLocalTrackIdFlow.asStateFlow()
private var sleepJob: Job? = null
fun setSpeed(speed: Float) {
val clamped = speed.coerceIn(0.5f, 2.0f)
_speedFlow.value = clamped
controller?.setPlaybackSpeed(clamped)
}
fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) {
controller?.repeatMode = mode
}
fun setShuffleEnabled(enabled: Boolean) {
controller?.shuffleModeEnabled = enabled
}
/** Start (or restart) the sleep timer; pauses playback when it fires. */
fun startSleepTimer(minutes: Int) {
sleepJob?.cancel()
if (minutes <= 0) {
_sleepRemainingMsFlow.value = 0L
return
}
val totalMs = minutes * 60_000L
val endAt = SystemClock.elapsedRealtime() + totalMs
sleepJob = scope.launch {
while (true) {
val remaining = endAt - SystemClock.elapsedRealtime()
if (remaining <= 0L) break
_sleepRemainingMsFlow.value = remaining
delay(1_000L)
}
_sleepRemainingMsFlow.value = 0L
sleepJob = null
// Pause rather than stop: the station stays loaded for when the
// user wakes up.
controller?.pause()
}
}
fun cancelSleepTimer() {
sleepJob?.cancel()
sleepJob = null
_sleepRemainingMsFlow.value = 0L
}
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
@ -144,7 +210,11 @@ class PlaybackManager(
_isLocalFlow.value = false
_localMediaFlow.value = null
_lastStreamMetaFlow.value = null
_currentLocalTrackIdFlow.value = null
val ctrl = controller ?: return
// A live stream has no queue: shuffle must not leak from local playback.
ctrl.shuffleModeEnabled = false
_shuffleEnabledFlow.value = false
stopAll()
initBuffer()
@ -162,40 +232,69 @@ class PlaybackManager(
.build()
ctrl.setMediaItem(mediaItem)
ctrl.setPlaybackSpeed(_speedFlow.value)
ctrl.prepare()
ctrl.play()
startIcyReader(url)
}
fun playLocalFile(filePath: String, title: String, artist: String) {
fun playLocalFile(filePath: String, title: String, artist: String, id: Long? = null) {
playLocalQueue(listOf(LocalTrack(id, filePath, title, artist)), startIndex = 0, shuffle = false)
}
/** A local library track for queue playback. `id` links UI state to the player. */
data class LocalTrack(val id: Long?, val filePath: String, val title: String, val artist: String)
/**
* Plays a local queue (library songs / playlist). ExoPlayer handles
* advance, shuffle order and repeat natively, so transport next/prev
* and end-of-track behave like a real music player.
*/
fun playLocalQueue(tracks: List<LocalTrack>, startIndex: Int, shuffle: Boolean) {
if (tracks.isEmpty()) return
currentStationUrl = null
currentStationName = ""
// Publish so the shared NowPlaying bar shows library playback too.
_currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist"
_currentStationUrlFlow.value = null
_isLocalFlow.value = true
_localMediaFlow.value = title to artist
val first = tracks[startIndex.coerceIn(0, tracks.lastIndex)]
_currentStationNameFlow.value =
if (first.artist.isBlank()) first.title else "${first.title} — ${first.artist}"
_localMediaFlow.value = first.title to first.artist
_lastStreamMetaFlow.value = null
val ctrl = controller ?: return
stopAll()
val mediaItem = MediaItem.Builder()
.setUri(filePath)
val items = tracks.map { track ->
MediaItem.Builder()
.setUri(track.filePath)
.setMediaId(track.id?.toString() ?: track.filePath)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(artist)
.setTitle(title)
.setArtist(track.artist)
.setTitle(track.title)
.build()
)
.build()
ctrl.setMediaItem(mediaItem)
}
ctrl.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex), 0L)
ctrl.shuffleModeEnabled = shuffle
_shuffleEnabledFlow.value = shuffle
ctrl.setPlaybackSpeed(_speedFlow.value)
ctrl.prepare()
ctrl.play()
}
/** Transport next within the current queue (no-op for a single stream). */
fun seekToNextItem() {
controller?.let { if (it.mediaItemCount > 1) it.seekToNext() }
}
fun seekToPreviousItem() {
controller?.let { if (it.mediaItemCount > 1) it.seekToPrevious() }
}
fun togglePlayPause() {
val ctrl = controller ?: return
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
@ -334,6 +433,27 @@ class PlaybackManager(
listener?.onPlaybackStateChanged(playing)
}
override fun onRepeatModeChanged(mode: Int) {
_repeatModeFlow.value = mode
}
override fun onShuffleModeEnabledChanged(enabled: Boolean) {
_shuffleEnabledFlow.value = enabled
}
/** Queue advanced (auto or manual): publish the new item's identity. */
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (_isLocalFlow.value && mediaItem != null) {
val title = mediaItem.mediaMetadata.title?.toString().orEmpty()
val artist = mediaItem.mediaMetadata.artist?.toString().orEmpty()
_localMediaFlow.value = title to artist
_currentStationNameFlow.value =
if (artist.isBlank()) title else "$title — $artist"
val itemId = mediaItem.mediaId.toLongOrNull()
_currentLocalTrackIdFlow.value = itemId
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {

View file

@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.random.Random
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
@ -116,6 +115,14 @@ class LibraryViewModel @Inject constructor(
}
}
init {
viewModelScope.launch {
playbackManager.currentLocalTrackIdFlow.collect { id ->
if (id != null) _uiState.update { it.copy(currentPlayingId = id) }
}
}
}
private fun sortSongs(
songs: List<SavedSongEntity>,
order: SortOrder,
@ -138,58 +145,30 @@ class LibraryViewModel @Inject constructor(
}
fun playSong(song: SavedSongEntity) {
playbackManager.playLocalFile(
filePath = song.filePath,
title = song.title,
artist = song.artist,
)
_uiState.update { it.copy(currentPlayingId = song.id, shuffleQueue = emptyList(), shuffleIndex = -1) }
// Queue the visible list from the tapped song so transport next/prev
// and end-of-track advance through the library natively (ExoPlayer).
val queue = _uiState.value.songs
val startIdx = queue.indexOfFirst { it.id == song.id }.coerceAtLeast(0)
playQueue(queue, startIdx, shuffle = false)
}
fun shufflePlay() {
val songs = _uiState.value.songs
if (songs.isEmpty()) return
val shuffled = songs.shuffled(Random)
_uiState.update { it.copy(shuffleQueue = shuffled, shuffleIndex = 0) }
val first = shuffled.first()
playbackManager.playLocalFile(
filePath = first.filePath,
title = first.title,
artist = first.artist,
)
_uiState.update { it.copy(currentPlayingId = first.id) }
val queue = _uiState.value.songs
if (queue.isEmpty()) return
playQueue(queue, startIndex = 0, shuffle = true)
}
fun playNextInShuffle(): Boolean {
val queue = _uiState.value.shuffleQueue
val idx = _uiState.value.shuffleIndex
if (queue.isEmpty() || idx < 0) return false
val nextIdx = idx + 1
if (nextIdx >= queue.size) return false
val next = queue[nextIdx]
playbackManager.playLocalFile(
filePath = next.filePath,
title = next.title,
artist = next.artist,
private fun playQueue(queue: List<SavedSongEntity>, startIndex: Int, shuffle: Boolean) {
playbackManager.playLocalQueue(
tracks = queue.map {
PlaybackManager.LocalTrack(it.id, it.filePath, it.title, it.artist)
},
startIndex = startIndex,
shuffle = shuffle,
)
_uiState.update { it.copy(currentPlayingId = next.id, shuffleIndex = nextIdx) }
return true
_uiState.update { it.copy(currentPlayingId = queue[startIndex].id) }
}
fun playPreviousInShuffle(): Boolean {
val queue = _uiState.value.shuffleQueue
val idx = _uiState.value.shuffleIndex
if (queue.isEmpty() || idx <= 0) return false
val prevIdx = idx - 1
val prev = queue[prevIdx]
playbackManager.playLocalFile(
filePath = prev.filePath,
title = prev.title,
artist = prev.artist,
)
_uiState.update { it.copy(currentPlayingId = prev.id, shuffleIndex = prevIdx) }
return true
}
fun toggleFavorite(songId: Long) {
viewModelScope.launch {
@ -264,9 +243,7 @@ data class LibraryUiState(
val selectedPlaylistId: Long? = null,
val sortOrder: SortOrder = SortOrder.DATE_SAVED,
val sortAscending: Boolean = true,
val shuffleQueue: List<SavedSongEntity> = emptyList(),
val shuffleIndex: Int = -1,
val isImporting: Boolean = false,
) {
val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0
}

View file

@ -34,6 +34,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOne
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material.icons.filled.TimerOff
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.LibraryMusic
@ -81,7 +87,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.TextButton
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -599,7 +604,7 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.width(12.dp))
IconButton(
onClick = { viewModel.previousStation() },
onClick = { viewModel.previous() },
enabled = uiState.currentStation != null || uiState.isLocal,
) {
Icon(
@ -644,17 +649,219 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = { viewModel.nextStation() },
enabled = uiState.currentStation != null,
onClick = { viewModel.next() },
enabled = uiState.currentStation != null || uiState.isLocal,
) {
Icon(
Icons.Default.SkipNext,
contentDescription = "Next station",
contentDescription = if (uiState.isLocal) "Next track" else "Next station",
modifier = Modifier.size(32.dp),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Playback options: sleep timer · speed · repeat · shuffle
var showSleepDialog by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val sleepActive = uiState.sleepRemainingMs > 0
IconButton(onClick = { showSleepDialog = true }) {
Icon(
if (sleepActive) Icons.Default.Timer else Icons.Default.TimerOff,
contentDescription = "Sleep timer",
tint = if (sleepActive)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary,
)
}
if (sleepActive) {
val mins = (uiState.sleepRemainingMs / 60_000L).toInt()
val secs = ((uiState.sleepRemainingMs % 60_000L) / 1_000L).toInt()
Text(
"%d:%02d".format(mins, secs),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = { showSpeedDialog = true }) {
Icon(
Icons.Default.Speed,
contentDescription = "Playback speed",
tint = if (uiState.speed != 1f)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary,
)
}
if (uiState.speed != 1f) {
Text(
"%.2f×".format(uiState.speed).replace(".00×", "×"),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = { viewModel.cycleRepeat() },
enabled = uiState.isLocal,
) {
Icon(
if (uiState.repeatMode == androidx.media3.common.Player.REPEAT_MODE_ONE)
Icons.Default.RepeatOne
else
Icons.Default.Repeat,
contentDescription = "Repeat",
tint = if (uiState.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary.copy(
alpha = if (uiState.isLocal) 1f else 0.38f,
),
)
}
IconButton(
onClick = { viewModel.toggleShuffle() },
enabled = uiState.isLocal,
) {
Icon(
Icons.Default.Shuffle,
contentDescription = "Shuffle",
tint = if (uiState.shuffleEnabled)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary.copy(
alpha = if (uiState.isLocal) 1f else 0.38f,
),
)
}
}
if (showSleepDialog) {
SleepTimerDialog(
activeMs = uiState.sleepRemainingMs,
onSelect = { minutes ->
if (minutes <= 0) viewModel.cancelSleepTimer()
else viewModel.startSleepTimer(minutes)
showSleepDialog = false
},
onDismiss = { showSleepDialog = false },
)
}
if (showSpeedDialog) {
SpeedDialog(
current = uiState.speed,
onSelect = { viewModel.setSpeed(it); showSpeedDialog = false },
onDismiss = { showSpeedDialog = false },
)
}
}
}
@Composable
private fun SleepTimerDialog(
activeMs: Long,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val tokens = LocalTokens.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Sleep timer") },
text = {
Column {
Text(
"Pause playback after…",
style = MaterialTheme.typography.bodyMedium,
color = tokens.textSecondary,
)
Spacer(modifier = Modifier.height(12.dp))
val options = listOf(15, 30, 45, 60, 90)
options.forEach { minutes ->
TextButton(
onClick = { onSelect(minutes) },
modifier = Modifier.fillMaxWidth(),
) {
Text(
"$minutes min",
modifier = Modifier.fillMaxWidth(),
color = tokens.text,
)
}
}
if (activeMs > 0) {
TextButton(
onClick = { onSelect(0) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Cancel timer", color = tokens.accentText,
modifier = Modifier.fillMaxWidth())
}
}
}
},
confirmButton = {},
containerColor = tokens.panel,
)
}
@Composable
private fun SpeedDialog(
current: Float,
onSelect: (Float) -> Unit,
onDismiss: () -> Unit,
) {
val tokens = LocalTokens.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Playback speed") },
text = {
Column {
val options = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f)
options.forEach { speed ->
val isCurrent = kotlin.math.abs(speed - current) < 0.01f
TextButton(
onClick = { onSelect(speed) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (speed == 1f) "Normal (1×)"
else "%.2f×".format(speed).replace(".00×", "×"),
modifier = Modifier.weight(1f),
color = if (isCurrent) tokens.accentText else tokens.text,
fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal,
)
if (isCurrent) {
Icon(Icons.Default.Check, contentDescription = null,
tint = tokens.accentText,
modifier = Modifier.size(18.dp))
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
"Applies to stations and saved songs.",
style = MaterialTheme.typography.bodySmall,
color = tokens.textMuted,
)
}
},
confirmButton = {},
containerColor = tokens.panel,
)
}
@Composable

View file

@ -146,6 +146,36 @@ class PlayerViewModel @Inject constructor(
)
}
restoreCachedStreamMeta()
// Keep local queue state (title/artist across track changes) live.
viewModelScope.launch {
playbackManager.localMediaFlow.collect { local ->
_uiState.update {
it.copy(
localTitle = local?.first.orEmpty(),
localArtist = local?.second.orEmpty(),
)
}
}
}
viewModelScope.launch {
playbackManager.speedFlow.collect { v -> _uiState.update { it.copy(speed = v) } }
}
viewModelScope.launch {
playbackManager.sleepRemainingMsFlow.collect { ms ->
_uiState.update { it.copy(sleepRemainingMs = ms) }
}
}
viewModelScope.launch {
playbackManager.repeatModeFlow.collect { mode ->
_uiState.update { it.copy(repeatMode = mode) }
}
}
viewModelScope.launch {
playbackManager.shuffleEnabledFlow.collect { on ->
_uiState.update { it.copy(shuffleEnabled = on) }
}
}
}
/** Re-apply the last ICY metadata so a reopened screen isn't blank. */
@ -191,6 +221,38 @@ class PlayerViewModel @Inject constructor(
}
}
/** Transport next: queue advance for local playback, station cycle for radio. */
fun next() {
if (_uiState.value.isLocal) { playbackManager.seekToNextItem(); return }
nextStation()
}
fun previous() {
if (_uiState.value.isLocal) { playbackManager.seekToPreviousItem(); return }
previousStation()
}
fun setSpeed(speed: Float) = playbackManager.setSpeed(speed)
fun cycleRepeat() {
val next = when (_uiState.value.repeatMode) {
androidx.media3.common.Player.REPEAT_MODE_OFF ->
androidx.media3.common.Player.REPEAT_MODE_ALL
androidx.media3.common.Player.REPEAT_MODE_ALL ->
androidx.media3.common.Player.REPEAT_MODE_ONE
else -> androidx.media3.common.Player.REPEAT_MODE_OFF
}
playbackManager.setRepeatMode(next)
}
fun toggleShuffle() {
playbackManager.setShuffleEnabled(!_uiState.value.shuffleEnabled)
}
fun startSleepTimer(minutes: Int) = playbackManager.startSleepTimer(minutes)
fun cancelSleepTimer() = playbackManager.cancelSleepTimer()
fun nextStation() {
val stations = _uiState.value.stations
if (stations.isEmpty()) return
@ -314,6 +376,10 @@ data class PlayerUiState(
val bufferBytesWritten: Long = 0,
val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE,
val lastSaveResult: SaveSongUseCase.SaveResult? = null,
val speed: Float = 1f,
val sleepRemainingMs: Long = 0L,
val repeatMode: Int = androidx.media3.common.Player.REPEAT_MODE_OFF,
val shuffleEnabled: Boolean = false,
val showPlaylistPicker: Boolean = false,
val pickedSongId: Long? = null,
val newPlaylistName: String = "",