Added Shuffle/Repeat functionality
This commit is contained in:
parent
8baa347e81
commit
7118596e1c
8 changed files with 614 additions and 10 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue