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)
|
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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = "",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue