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:
parent
ee0d6b6149
commit
ec55ba84ae
4 changed files with 440 additions and 70 deletions
|
|
@ -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)
|
||||
.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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<com.rada.data.local.entity.PlaylistEntity>,
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue