Added Shuffle/Repeat functionality

This commit is contained in:
OmniaX-Dev 2026-06-23 13:24:49 +02:00
parent 8baa347e81
commit 7118596e1c
8 changed files with 614 additions and 10 deletions

View file

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-23T09:36:00.583173984Z">
<DropdownSelection timestamp="2026-06-23T10:17:32.872144833Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/sylar/.android/avd/Medium_Phone.avd" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCY20YA30P" />
</handle>
</Target>
</DropdownSelection>

View file

@ -0,0 +1,61 @@
package com.keylightpiano.pianotimeline.player
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import java.io.File
/**
* Holds the ONE and only SimpleCache instance for the whole app.
*
* SimpleCache is extremely strict: you may construct exactly one instance
* pointing at a given directory for the lifetime of the process. A second
* instance on the same folder throws. So this MUST be a singleton.
*
* The cache lives in the app's cache dir (`cache/media`), so the OS can reclaim
* it under storage pressure and "clear app data" wipes it. Capacity is capped
* with an LRU evictor once we exceed the limit, least-recently-used media is
* dropped automatically.
*/
@OptIn(UnstableApi::class)
object MediaCache {
// 512 MB cap — generous for piano recordings, which are a few MB each.
private const val MAX_CACHE_BYTES = 512L * 1024L * 1024L
@Volatile
private var instance: SimpleCache? = null
fun get(context: Context): SimpleCache {
return instance ?: synchronized(this) {
instance ?: createCache(context).also { instance = it }
}
}
private fun createCache(context: Context): SimpleCache {
val dir = File(context.cacheDir, "media")
val evictor = LeastRecentlyUsedCacheEvictor(MAX_CACHE_BYTES)
val dbProvider = StandaloneDatabaseProvider(context)
return SimpleCache(dir, evictor, dbProvider)
}
/**
* Total bytes currently cached. Useful for a "clear cache" settings screen
* that wants to show how much is stored.
*/
fun cacheSizeBytes(context: Context): Long = get(context).cacheSpace
/**
* Clears everything from the cache. Must be called when no player is actively
* reading call it while playback is stopped.
*/
fun clear(context: Context) {
val cache = get(context)
cache.keys.toList().forEach { key ->
cache.removeResource(key)
}
}
}

View file

@ -0,0 +1,19 @@
package com.keylightpiano.pianotimeline.player
/**
* Immutable snapshot of what the player is doing right now.
* The UI observes this to know which card is playing, button states, etc.
*
* `nowPlayingId` is the recording id currently loaded (or null if nothing).
* The list cards compare their own id against this to decide whether to show
* the pause icon + red title.
*/
data class PlaybackState(
val nowPlayingId: String? = null,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L
) {
val hasActiveTrack: Boolean get() = nowPlayingId != null
}

View file

@ -0,0 +1,156 @@
package com.keylightpiano.pianotimeline.player
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Owns a single ExoPlayer and exposes its state as a Flow.
*
* Caching: media is read through a CacheDataSource backed by the app-wide
* MediaCache singleton. First play of a track streams from the network and
* writes to disk simultaneously; subsequent plays read straight from disk.
*
* Threading: ExoPlayer must be created and called on a single thread (the main
* thread here, which is where Compose callbacks land). The position-polling loop
* runs on a coroutine but only reads player state, which is main-safe because we
* launch it on the main dispatcher.
*/
@OptIn(UnstableApi::class)
class PlayerManager(
private val context: Context,
private val scope: CoroutineScope,
private val onTrackEnded: () -> Unit = {}
) {
private val _state = MutableStateFlow(PlaybackState())
val state: StateFlow<PlaybackState> = _state.asStateFlow()
// The recording id currently loaded into the player.
private var currentId: String? = null
private var positionJob: Job? = null
private val cacheDataSourceFactory: CacheDataSource.Factory by lazy {
val upstream = DefaultHttpDataSource.Factory()
CacheDataSource.Factory()
.setCache(MediaCache.get(context))
.setUpstreamDataSourceFactory(upstream)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
private val player: ExoPlayer by lazy {
ExoPlayer.Builder(context)
.setMediaSourceFactory(ProgressiveMediaSource.Factory(cacheDataSourceFactory))
.build()
.also { it.addListener(playerListener) }
}
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
if (playbackState == Player.STATE_READY) startPositionLoop()
if (playbackState == Player.STATE_ENDED) {
stopPositionLoop()
onTrackEnded()
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
updateState()
if (isPlaying) startPositionLoop() else stopPositionLoop()
}
}
/**
* Play a recording from its full (already percent-encoded) URL.
* If the same recording is tapped while playing, this toggles pause/resume.
*/
fun playOrToggle(recordingId: String, url: String) {
if (recordingId == currentId) {
// Same track — toggle play/pause.
if (player.isPlaying) player.pause() else player.play()
return
}
// New track.
currentId = recordingId
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
player.play()
updateState()
}
fun pause() {
player.pause()
}
fun resume() {
player.play()
}
/** Restart the current track from the beginning (used by Repeat One). */
fun replayCurrent() {
player.seekTo(0)
player.play()
updateState()
}
fun seekTo(positionMs: Long) {
player.seekTo(positionMs)
updateState()
}
/** Stop and clear the current track entirely (mini-player dismissed). */
fun stop() {
player.stop()
player.clearMediaItems()
currentId = null
stopPositionLoop()
_state.value = PlaybackState()
}
private fun startPositionLoop() {
if (positionJob?.isActive == true) return
positionJob = scope.launch {
while (true) {
updateState()
delay(200) // 5x/sec — smooth enough for a progress bar
}
}
}
private fun stopPositionLoop() {
positionJob?.cancel()
positionJob = null
}
private fun updateState() {
_state.value = PlaybackState(
nowPlayingId = currentId,
isPlaying = player.isPlaying,
isBuffering = player.playbackState == Player.STATE_BUFFERING,
positionMs = player.currentPosition.coerceAtLeast(0L),
durationMs = player.duration.coerceAtLeast(0L)
)
}
/** Release the player. Call when the owning ViewModel is cleared. */
fun release() {
stopPositionLoop()
player.removeListener(playerListener)
player.release()
}
}

View file

@ -0,0 +1,169 @@
package com.keylightpiano.pianotimeline.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.PlaybackState
/**
* Compact now-playing bar pinned to the bottom. Shows the active track's
* artist/title, a play/pause toggle, a thin progress bar, and a dismiss button.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MiniPlayer(
recording: Recording,
playback: PlaybackState,
onTogglePlayPause: () -> Unit,
onSeek: (Long) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 3.dp,
shadowElevation = 8.dp
) {
Column {
// While the user is dragging, we hold a local position so the thumb
// follows the finger instead of snapping back to the polled playhead.
var isDragging by remember { mutableStateOf(false) }
var dragValueMs by remember { mutableFloatStateOf(0f) }
val duration = playback.durationMs.coerceAtLeast(1L)
val sliderValue = if (isDragging) dragValueMs
else playback.positionMs.toFloat().coerceIn(0f, duration.toFloat())
Slider(
value = sliderValue,
onValueChange = { v ->
isDragging = true
dragValueMs = v
},
onValueChangeFinished = {
onSeek(dragValueMs.toLong())
isDragging = false
},
valueRange = 0f..duration.toFloat(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp),
thumb = {
// Wider than tall, rounded — a little "pill" instead of a circle.
Box(
modifier = Modifier
.size(width = 12.dp, height = 13.dp)
.clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.primary)
)
},
track = { sliderState ->
val fraction = if (sliderState.valueRange.endInclusive > sliderState.valueRange.start) {
(sliderState.value - sliderState.valueRange.start) /
(sliderState.valueRange.endInclusive - sliderState.valueRange.start)
} else 0f
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp) // ~half the default 4dp track
) {
// Inactive (full width, dim)
Box(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f))
)
// Active (filled portion)
Box(
modifier = Modifier
.fillMaxWidth(fraction.coerceIn(0f, 1f))
.height(2.dp)
.clip(RoundedCornerShape(1.dp))
.background(MaterialTheme.colorScheme.primary)
)
}
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onTogglePlayPause, modifier = Modifier.size(44.dp)) {
Icon(
painter = painterResource(
if (playback.isPlaying) android.R.drawable.ic_media_pause
else android.R.drawable.ic_media_play
),
contentDescription = if (playback.isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = recording.artist,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = recording.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Stop",
modifier = Modifier.size(20.dp)
)
}
}
}
}
}

View file

@ -35,10 +35,12 @@ import com.keylightpiano.pianotimeline.domain.Recording
private val NotesGreen = Color(0xFF25B300)
private val NewBadgeRed = Color(0xFFE53935)
private val PlayingRed = Color(0xFFFF3D3D)
@Composable
fun RecordingItem(
recording: Recording,
isPlaying: Boolean,
onPlayClick: () -> Unit,
onFavoriteClick: () -> Unit,
onNotesClick: (() -> Unit)?,
@ -63,8 +65,11 @@ fun RecordingItem(
modifier = Modifier.size(44.dp)
) {
Icon(
painter = painterResource(android.R.drawable.ic_media_play),
contentDescription = "Play",
painter = painterResource(
if (isPlaying) android.R.drawable.ic_media_pause
else android.R.drawable.ic_media_play
),
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(22.dp)
)
}
@ -75,7 +80,7 @@ fun RecordingItem(
Text(
text = recording.artist,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
color = Color(0xFFE6A900),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -83,6 +88,8 @@ fun RecordingItem(
text = recording.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (isPlaying) PlayingRed
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
@ -98,21 +105,21 @@ fun RecordingItem(
Text(
text = recording.formattedDate,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = Color(0xFFA3D1FF)
)
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = recording.formattedDuration,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = Color(0xFFA3FFBC)
)
Text("\u00B7", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = recording.performanceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = Color(0xFF999999)
)
}
}

View file

@ -22,7 +22,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOne
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
@ -70,6 +73,9 @@ fun RecordingListScreen(
val syncState by vm.syncState.collectAsStateWithLifecycle()
val recordings by vm.recordings.collectAsStateWithLifecycle()
val isRefreshing by vm.isRefreshing.collectAsStateWithLifecycle()
val playbackState by vm.playbackState.collectAsStateWithLifecycle()
val shuffle by vm.shuffle.collectAsStateWithLifecycle()
val repeatMode by vm.repeatMode.collectAsStateWithLifecycle()
val searchQuery by vm.searchQuery.collectAsStateWithLifecycle()
val sortMode by vm.sortMode.collectAsStateWithLifecycle()
val hiddenArtists by vm.hiddenArtists.collectAsStateWithLifecycle()
@ -116,6 +122,13 @@ fun RecordingListScreen(
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hasActiveFilter = searchQuery.isNotEmpty() || hiddenArtists.isNotEmpty()
// The recording matching the active track, if any — used by the mini-player.
// We look in the full unfiltered list so the bar stays even if a filter would
// hide the playing track.
val nowPlaying = remember(playbackState.nowPlayingId, recordings) {
playbackState.nowPlayingId?.let { id -> recordings.firstOrNull { it.id == id } }
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@ -133,6 +146,27 @@ fun RecordingListScreen(
}
},
actions = {
// Shuffle toggle
IconButton(onClick = { vm.toggleShuffle() }) {
Icon(
imageVector = Icons.Default.Shuffle,
contentDescription = "Shuffle",
tint = if (shuffle) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 3-state repeat
IconButton(onClick = { vm.cycleRepeatMode() }) {
Icon(
imageVector = if (repeatMode == RepeatMode.ONE)
Icons.Default.RepeatOne else Icons.Default.Repeat,
contentDescription = "Repeat",
tint = if (repeatMode == RepeatMode.OFF)
MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.primary
)
}
// Search & filter
IconButton(onClick = { showControls = !showControls }) {
Icon(
imageVector = Icons.Default.Search,
@ -144,6 +178,17 @@ fun RecordingListScreen(
},
scrollBehavior = scrollBehavior
)
},
bottomBar = {
if (nowPlaying != null) {
MiniPlayer(
recording = nowPlaying,
playback = playbackState,
onTogglePlayPause = { vm.togglePlayPause() },
onSeek = { vm.seekTo(it) },
onDismiss = { vm.stopPlayback() }
)
}
}
) { innerPadding ->
when (val s = syncState) {
@ -288,6 +333,8 @@ fun RecordingListScreen(
items(items = recordings, key = { it.id }) { recording ->
RecordingItem(
recording = recording,
isPlaying = playbackState.nowPlayingId == recording.id
&& playbackState.isPlaying,
onPlayClick = { vm.onPlay(recording) },
onFavoriteClick = { onFavoriteToggle(recording) },
onNotesClick = if (recording.hasMidi) {

View file

@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.PlaybackState
import com.keylightpiano.pianotimeline.player.PlayerManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -26,6 +28,26 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app)
private val syncManager = SyncManager(app)
private val playerManager = PlayerManager(app, viewModelScope, onTrackEnded = { advanceToNext() })
/** Playback state the UI observes (which card is playing, position, etc). */
val playbackState: StateFlow<PlaybackState> = playerManager.state
// ── Continuous play: repeat + shuffle ──────────────────────────────
private val _repeatMode = MutableStateFlow(RepeatMode.OFF)
val repeatMode: StateFlow<RepeatMode> = _repeatMode.asStateFlow()
private val _shuffle = MutableStateFlow(false)
val shuffle: StateFlow<Boolean> = _shuffle.asStateFlow()
/**
* When shuffle is on, this holds a shuffled permutation of recording IDs
* representing the play order for the current pass. Rebuilt when shuffle is
* toggled on, when a track is manually started, or when a shuffled pass
* completes under Repeat All.
*/
private var shuffleOrder: List<String> = emptyList()
// ── Sync state ─────────────────────────────────────────────────────
@ -134,12 +156,132 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
}
}
/** Called when a recording's play button is pressed. Clears its "new" badge. */
/**
* Called when a recording's play button is pressed. Clears its "new" badge,
* then plays (or toggles pause/resume if it's already the active track).
*/
fun onPlay(recording: Recording) {
if (recording.isNew) {
viewModelScope.launch { repo.clearNew(recording.id) }
}
// TODO: actual playback wiring comes with the player layer
// A manual play (re)bases the shuffle order around the chosen track.
if (_shuffle.value) rebuildShuffleOrder(startId = recording.id)
val url = repo.audioUrl(recording)
playerManager.playOrToggle(recording.id, url)
}
/** Mini-player controls. */
fun togglePlayPause() {
val s = playbackState.value
if (s.isPlaying) playerManager.pause() else playerManager.resume()
}
fun seekTo(positionMs: Long) = playerManager.seekTo(positionMs)
fun stopPlayback() = playerManager.stop()
// ── Repeat / shuffle controls ──────────────────────────────────────
fun cycleRepeatMode() {
_repeatMode.value = when (_repeatMode.value) {
RepeatMode.OFF -> RepeatMode.ALL
RepeatMode.ALL -> RepeatMode.ONE
RepeatMode.ONE -> RepeatMode.OFF
}
}
fun toggleShuffle() {
val newValue = !_shuffle.value
_shuffle.value = newValue
if (newValue) {
// Seed the shuffle around whatever's currently playing (if anything).
rebuildShuffleOrder(startId = playbackState.value.nowPlayingId)
}
}
/**
* Build a fresh shuffled permutation of the currently-visible recordings.
* If startId is given, that track is placed first so the current song isn't
* interrupted and shuffle continues from it.
*/
private fun rebuildShuffleOrder(startId: String?) {
val ids = recordings.value.map { it.id }.toMutableList()
ids.shuffle()
if (startId != null) {
ids.remove(startId)
ids.add(0, startId)
}
shuffleOrder = ids
}
/**
* Advance to the next track when one finishes naturally.
* Honors repeat-one (replay), shuffle (next in permutation), and
* repeat-all (wrap / reshuffle). Repeat-off stops at the end.
*/
private fun advanceToNext() {
val current = playbackState.value.nowPlayingId ?: return
// Repeat One: just replay the same track.
if (_repeatMode.value == RepeatMode.ONE) {
playerManager.replayCurrent()
return
}
val visible = recordings.value
if (visible.isEmpty()) {
playerManager.stop()
return
}
val nextRecording: Recording? = if (_shuffle.value) {
nextShuffled(current, visible)
} else {
nextSequential(current, visible)
}
if (nextRecording != null) {
val url = repo.audioUrl(nextRecording)
playerManager.playOrToggle(nextRecording.id, url)
} else {
// End of queue with Repeat Off → stop.
playerManager.stop()
}
}
private fun nextSequential(currentId: String, visible: List<Recording>): Recording? {
val idx = visible.indexOfFirst { it.id == currentId }
if (idx == -1) return visible.firstOrNull() // current fell out of the list
val nextIdx = idx + 1
return when {
nextIdx < visible.size -> visible[nextIdx]
_repeatMode.value == RepeatMode.ALL -> visible.firstOrNull() // wrap
else -> null // end, repeat off
}
}
private fun nextShuffled(currentId: String, visible: List<Recording>): Recording? {
// Keep the shuffle order in sync with what's actually visible.
val visibleIds = visible.map { it.id }.toSet()
shuffleOrder = shuffleOrder.filter { it in visibleIds }
if (shuffleOrder.isEmpty()) rebuildShuffleOrder(startId = currentId)
val idx = shuffleOrder.indexOf(currentId)
val nextId = when {
idx != -1 && idx + 1 < shuffleOrder.size -> shuffleOrder[idx + 1]
_repeatMode.value == RepeatMode.ALL -> {
// Completed a pass — reshuffle for the next one.
rebuildShuffleOrder(startId = null)
shuffleOrder.firstOrNull()
}
else -> null
}
return nextId?.let { id -> visible.firstOrNull { it.id == id } }
}
override fun onCleared() {
super.onCleared()
playerManager.release()
}
/**
@ -180,6 +322,9 @@ sealed interface SyncState {
data class Error(val message: String) : SyncState
}
/** Three-state repeat, like a normal audio player. */
enum class RepeatMode { OFF, ALL, ONE }
// ── Private helpers ────────────────────────────────────────────────────
/**