From ec55ba84ae0824bf5ede4aef8567910933dd30dd Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 01:30:43 -0500 Subject: [PATCH] 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 --- .../java/com/rada/player/PlaybackManager.kt | 150 ++++++++++-- .../com/rada/ui/library/LibraryViewModel.kt | 73 ++---- .../java/com/rada/ui/player/PlayerScreen.kt | 217 +++++++++++++++++- .../com/rada/ui/player/PlayerViewModel.kt | 66 ++++++ 4 files changed, 438 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index 30b2d35..7ae19f8 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -90,6 +90,72 @@ class PlaybackManager( private val _lastStreamMetaFlow = MutableStateFlow(null) val lastStreamMetaFlow: StateFlow = _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 = _speedFlow.asStateFlow() + + private val _repeatModeFlow = MutableStateFlow(androidx.media3.common.Player.REPEAT_MODE_OFF) + val repeatModeFlow: StateFlow = _repeatModeFlow.asStateFlow() + + private val _shuffleEnabledFlow = MutableStateFlow(false) + val shuffleEnabledFlow: StateFlow = _shuffleEnabledFlow.asStateFlow() + + /** Milliseconds left on the sleep timer; 0 when off. */ + private val _sleepRemainingMsFlow = MutableStateFlow(0L) + val sleepRemainingMsFlow: StateFlow = _sleepRemainingMsFlow.asStateFlow() + + /** Library song id of the current queue item (null when not queue-playing). */ + private val _currentLocalTrackIdFlow = MutableStateFlow(null) + val currentLocalTrackIdFlow: StateFlow = _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 = _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, 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) - .setMediaMetadata( - MediaMetadata.Builder() - .setArtist(artist) - .setTitle(title) - .build() - ) - .build() - - ctrl.setMediaItem(mediaItem) + val items = tracks.map { track -> + MediaItem.Builder() + .setUri(track.filePath) + .setMediaId(track.id?.toString() ?: track.filePath) + .setMediaMetadata( + MediaMetadata.Builder() + .setArtist(track.artist) + .setTitle(track.title) + .build() + ) + .build() + } + 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 -> { diff --git a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt index 1e186fc..efbe24a 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt @@ -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, 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, 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 = emptyList(), - val shuffleIndex: Int = -1, + val isImporting: Boolean = false, ) { - val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0 } diff --git a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt index 2b5c713..b077843 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -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,19 +649,221 @@ 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 private fun PlaylistPickerDialog( playlists: List, diff --git a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt index 48e197d..81ab0e9 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt @@ -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 = "",