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) private val _lastStreamMetaFlow = MutableStateFlow<StreamMetadata?>(null)
val lastStreamMetaFlow: StateFlow<StreamMetadata?> = _lastStreamMetaFlow.asStateFlow() 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) private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow() val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
@ -144,7 +210,11 @@ class PlaybackManager(
_isLocalFlow.value = false _isLocalFlow.value = false
_localMediaFlow.value = null _localMediaFlow.value = null
_lastStreamMetaFlow.value = null _lastStreamMetaFlow.value = null
_currentLocalTrackIdFlow.value = null
val ctrl = controller ?: return val ctrl = controller ?: return
// A live stream has no queue: shuffle must not leak from local playback.
ctrl.shuffleModeEnabled = false
_shuffleEnabledFlow.value = false
stopAll() stopAll()
initBuffer() initBuffer()
@ -162,40 +232,69 @@ class PlaybackManager(
.build() .build()
ctrl.setMediaItem(mediaItem) ctrl.setMediaItem(mediaItem)
ctrl.setPlaybackSpeed(_speedFlow.value)
ctrl.prepare() ctrl.prepare()
ctrl.play() ctrl.play()
startIcyReader(url) 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 currentStationUrl = null
currentStationName = "" currentStationName = ""
// Publish so the shared NowPlaying bar shows library playback too.
_currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist"
_currentStationUrlFlow.value = null _currentStationUrlFlow.value = null
_isLocalFlow.value = true _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 _lastStreamMetaFlow.value = null
val ctrl = controller ?: return val ctrl = controller ?: return
stopAll() stopAll()
val mediaItem = MediaItem.Builder() val items = tracks.map { track ->
.setUri(filePath) MediaItem.Builder()
.setUri(track.filePath)
.setMediaId(track.id?.toString() ?: track.filePath)
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setArtist(artist) .setArtist(track.artist)
.setTitle(title) .setTitle(track.title)
.build() .build()
) )
.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.prepare()
ctrl.play() 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() { fun togglePlayPause() {
val ctrl = controller ?: return val ctrl = controller ?: return
if (ctrl.isPlaying) ctrl.pause() else ctrl.play() if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
@ -334,6 +433,27 @@ class PlaybackManager(
listener?.onPlaybackStateChanged(playing) 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) { override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) { when (playbackState) {
Player.STATE_READY -> { Player.STATE_READY -> {

View file

@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.random.Random
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @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( private fun sortSongs(
songs: List<SavedSongEntity>, songs: List<SavedSongEntity>,
order: SortOrder, order: SortOrder,
@ -138,58 +145,30 @@ class LibraryViewModel @Inject constructor(
} }
fun playSong(song: SavedSongEntity) { fun playSong(song: SavedSongEntity) {
playbackManager.playLocalFile( // Queue the visible list from the tapped song so transport next/prev
filePath = song.filePath, // and end-of-track advance through the library natively (ExoPlayer).
title = song.title, val queue = _uiState.value.songs
artist = song.artist, val startIdx = queue.indexOfFirst { it.id == song.id }.coerceAtLeast(0)
) playQueue(queue, startIdx, shuffle = false)
_uiState.update { it.copy(currentPlayingId = song.id, shuffleQueue = emptyList(), shuffleIndex = -1) }
} }
fun shufflePlay() { fun shufflePlay() {
val songs = _uiState.value.songs val queue = _uiState.value.songs
if (songs.isEmpty()) return if (queue.isEmpty()) return
val shuffled = songs.shuffled(Random) playQueue(queue, startIndex = 0, shuffle = true)
_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) }
} }
fun playNextInShuffle(): Boolean { private fun playQueue(queue: List<SavedSongEntity>, startIndex: Int, shuffle: Boolean) {
val queue = _uiState.value.shuffleQueue playbackManager.playLocalQueue(
val idx = _uiState.value.shuffleIndex tracks = queue.map {
if (queue.isEmpty() || idx < 0) return false PlaybackManager.LocalTrack(it.id, it.filePath, it.title, it.artist)
val nextIdx = idx + 1 },
if (nextIdx >= queue.size) return false startIndex = startIndex,
val next = queue[nextIdx] shuffle = shuffle,
playbackManager.playLocalFile(
filePath = next.filePath,
title = next.title,
artist = next.artist,
) )
_uiState.update { it.copy(currentPlayingId = next.id, shuffleIndex = nextIdx) } _uiState.update { it.copy(currentPlayingId = queue[startIndex].id) }
return true
} }
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) { fun toggleFavorite(songId: Long) {
viewModelScope.launch { viewModelScope.launch {
@ -264,9 +243,7 @@ data class LibraryUiState(
val selectedPlaylistId: Long? = null, val selectedPlaylistId: Long? = null,
val sortOrder: SortOrder = SortOrder.DATE_SAVED, val sortOrder: SortOrder = SortOrder.DATE_SAVED,
val sortAscending: Boolean = true, val sortAscending: Boolean = true,
val shuffleQueue: List<SavedSongEntity> = emptyList(),
val shuffleIndex: Int = -1,
val isImporting: Boolean = false, 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.Check
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.LibraryMusic
@ -81,7 +87,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.TextButton
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -599,7 +604,7 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
IconButton( IconButton(
onClick = { viewModel.previousStation() }, onClick = { viewModel.previous() },
enabled = uiState.currentStation != null || uiState.isLocal, enabled = uiState.currentStation != null || uiState.isLocal,
) { ) {
Icon( Icon(
@ -644,17 +649,219 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
IconButton( IconButton(
onClick = { viewModel.nextStation() }, onClick = { viewModel.next() },
enabled = uiState.currentStation != null, enabled = uiState.currentStation != null || uiState.isLocal,
) { ) {
Icon( Icon(
Icons.Default.SkipNext, Icons.Default.SkipNext,
contentDescription = "Next station", contentDescription = if (uiState.isLocal) "Next track" else "Next station",
modifier = Modifier.size(32.dp), 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 @Composable

View file

@ -146,6 +146,36 @@ class PlayerViewModel @Inject constructor(
) )
} }
restoreCachedStreamMeta() 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. */ /** 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() { fun nextStation() {
val stations = _uiState.value.stations val stations = _uiState.value.stations
if (stations.isEmpty()) return if (stations.isEmpty()) return
@ -314,6 +376,10 @@ data class PlayerUiState(
val bufferBytesWritten: Long = 0, val bufferBytesWritten: Long = 0,
val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE, val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE,
val lastSaveResult: SaveSongUseCase.SaveResult? = null, 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 showPlaylistPicker: Boolean = false,
val pickedSongId: Long? = null, val pickedSongId: Long? = null,
val newPlaylistName: String = "", val newPlaylistName: String = "",