diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml
index 325f104..968c819 100644
--- a/.idea/planningMode.xml
+++ b/.idea/planningMode.xml
@@ -5,6 +5,8 @@
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt
new file mode 100644
index 0000000..5e4fd81
--- /dev/null
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt
@@ -0,0 +1,54 @@
+package com.keylightpiano.pianotimeline.data
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.first
+
+/**
+ * Persists user UI preferences (shuffle, repeat mode, …) across app launches.
+ *
+ * This uses its own DataStore file ("user_prefs"), separate from the sync
+ * revision store, so playback preferences and sync bookkeeping stay decoupled.
+ *
+ * Values are stored as primitives (Boolean, String) and read back on ViewModel
+ * init. Reads are one-shot (`first()`) because we only need the value at startup;
+ * after that the ViewModel holds the source of truth and writes through on change.
+ */
+private val Context.userPrefsStore: DataStore by preferencesDataStore(name = "user_prefs")
+
+class SettingsStore(private val context: Context) {
+
+ private companion object {
+ val KEY_SHUFFLE = booleanPreferencesKey("shuffle_enabled")
+ val KEY_REPEAT = stringPreferencesKey("repeat_mode")
+ }
+
+ /** Read the stored shuffle flag (default false). */
+ suspend fun getShuffle(): Boolean {
+ val prefs = context.userPrefsStore.data.first()
+ return prefs[KEY_SHUFFLE] ?: false
+ }
+
+ suspend fun setShuffle(enabled: Boolean) {
+ context.userPrefsStore.edit { it[KEY_SHUFFLE] = enabled }
+ }
+
+ /**
+ * Read the stored repeat mode by name (default "OFF").
+ * Stored as the enum's name string so it's human-readable and resilient to
+ * reordering the enum constants.
+ */
+ suspend fun getRepeatModeName(): String {
+ val prefs = context.userPrefsStore.data.first()
+ return prefs[KEY_REPEAT] ?: "OFF"
+ }
+
+ suspend fun setRepeatModeName(name: String) {
+ context.userPrefsStore.edit { it[KEY_REPEAT] = name }
+ }
+}
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt
index 789e1cb..1352948 100644
--- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt
@@ -1,6 +1,8 @@
package com.keylightpiano.pianotimeline.ui
import androidx.compose.foundation.background
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -29,6 +31,7 @@ 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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -47,6 +50,8 @@ fun MiniPlayer(
playback: PlaybackState,
onTogglePlayPause: () -> Unit,
onSeek: (Long) -> Unit,
+ onPrevious: () -> Unit,
+ onNext: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -66,66 +71,89 @@ fun MiniPlayer(
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(),
+ Row(
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
+ .padding(start = 8.dp, end = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Slider(
+ value = sliderValue,
+ onValueChange = { v ->
+ isDragging = true
+ dragValueMs = v
+ },
+ onValueChangeFinished = {
+ onSeek(dragValueMs.toLong())
+ isDragging = false
+ },
+ valueRange = 0f..duration.toFloat(),
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 8.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(Color(0xFF23A300))
+ )
+ },
+ 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)
- )
+ .height(2.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(Color(0xFF25B300))
+ )
+ }
}
- }
- )
+ )
+
+ Text(
+ text = "${formatTime(playback.positionMs)} / ${formatTime(playback.durationMs)}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1
+ )
+ }
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 8.dp),
+ .padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
+
+ IconButton(onClick = onPrevious, modifier = Modifier.size(40.dp)) {
+ Icon(
+ painter = painterResource(android.R.drawable.ic_media_previous),
+ contentDescription = "Previous",
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
IconButton(onClick = onTogglePlayPause, modifier = Modifier.size(44.dp)) {
Icon(
painter = painterResource(
@@ -137,13 +165,21 @@ fun MiniPlayer(
)
}
+ IconButton(onClick = onNext, modifier = Modifier.size(40.dp)) {
+ Icon(
+ painter = painterResource(android.R.drawable.ic_media_next),
+ contentDescription = "Next",
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = recording.artist,
style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.primary,
+ color = Color(0xFF25B300),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -152,10 +188,17 @@ fun MiniPlayer(
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ modifier = Modifier.basicMarquee(
+ iterations = Int.MAX_VALUE,
+ repeatDelayMillis = 2000,
+ initialDelayMillis = 3000,
+ velocity = 30.dp
+ )
)
}
+ Spacer(Modifier.width(8.dp))
+
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Close,
@@ -167,3 +210,11 @@ fun MiniPlayer(
}
}
}
+
+/** Milliseconds → "m:ss". Negative/unknown durations render as 0:00. */
+private fun formatTime(ms: Long): String {
+ val totalSeconds = (ms.coerceAtLeast(0L) / 1000L).toInt()
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return "%d:%02d".format(minutes, seconds)
+}
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt
index 8aca2c0..100fc60 100644
--- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt
@@ -186,6 +186,8 @@ fun RecordingListScreen(
playback = playbackState,
onTogglePlayPause = { vm.togglePlayPause() },
onSeek = { vm.seekTo(it) },
+ onPrevious = { vm.skipPrevious() },
+ onNext = { vm.skipNext() },
onDismiss = { vm.stopPlayback() }
)
}
diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt
index acf324b..7e2328c 100644
--- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt
+++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.MusicRepository
+import com.keylightpiano.pianotimeline.data.SettingsStore
import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.PlaybackState
@@ -28,6 +29,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app)
private val syncManager = SyncManager(app)
+ private val settings = SettingsStore(app)
private val playerManager = PlayerManager(app, viewModelScope, onTrackEnded = { advanceToNext() })
/** Playback state the UI observes (which card is playing, position, etc). */
@@ -110,6 +112,14 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
// ── Init: run sync on creation ─────────────────────────────────────
init {
+ // Restore persisted playback preferences (shuffle / repeat) before sync.
+ viewModelScope.launch {
+ _shuffle.value = settings.getShuffle()
+ _repeatMode.value = runCatching {
+ RepeatMode.valueOf(settings.getRepeatModeName())
+ }.getOrDefault(RepeatMode.OFF)
+ }
+
viewModelScope.launch {
_syncState.value = SyncState.Syncing
val result = syncManager.sync()
@@ -180,19 +190,83 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
fun stopPlayback() = playerManager.stop()
+ /**
+ * Manual "next" button — behaves like a natural track-end advance,
+ * honoring shuffle and repeat-all, but a manual skip should always move on
+ * even under Repeat One (you don't want the button to just replay).
+ */
+ fun skipNext() {
+ val current = playbackState.value.nowPlayingId ?: return
+ val visible = recordings.value
+ if (visible.isEmpty()) return
+
+ val next = if (_shuffle.value) nextShuffled(current, visible)
+ else nextSequential(current, visible)
+ // If we're at the end with Repeat Off, wrap anyway on a manual skip.
+ val target = next ?: visible.firstOrNull()
+ target?.let { playerManager.playOrToggle(it.id, repo.audioUrl(it)) }
+ }
+
+ /**
+ * Manual "previous" button. Standard player behavior: if we're more than
+ * ~3 seconds into the track, restart it; otherwise jump to the previous one.
+ */
+ fun skipPrevious() {
+ val current = playbackState.value.nowPlayingId ?: return
+ if (playbackState.value.positionMs > 3000L) {
+ playerManager.replayCurrent()
+ return
+ }
+ val visible = recordings.value
+ if (visible.isEmpty()) return
+
+ val prev = if (_shuffle.value) previousShuffled(current, visible)
+ else previousSequential(current, visible)
+ val target = prev ?: visible.lastOrNull()
+ target?.let { playerManager.playOrToggle(it.id, repo.audioUrl(it)) }
+ }
+
+ private fun previousSequential(currentId: String, visible: List): Recording? {
+ val idx = visible.indexOfFirst { it.id == currentId }
+ if (idx == -1) return visible.firstOrNull()
+ val prevIdx = idx - 1
+ return when {
+ prevIdx >= 0 -> visible[prevIdx]
+ _repeatMode.value == RepeatMode.ALL -> visible.lastOrNull() // wrap to end
+ else -> null
+ }
+ }
+
+ private fun previousShuffled(currentId: String, visible: List): Recording? {
+ 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 prevId = when {
+ idx > 0 -> shuffleOrder[idx - 1]
+ _repeatMode.value == RepeatMode.ALL -> shuffleOrder.lastOrNull()
+ else -> null
+ }
+ return prevId?.let { id -> visible.firstOrNull { it.id == id } }
+ }
+
// ── Repeat / shuffle controls ──────────────────────────────────────
fun cycleRepeatMode() {
- _repeatMode.value = when (_repeatMode.value) {
+ val next = when (_repeatMode.value) {
RepeatMode.OFF -> RepeatMode.ALL
RepeatMode.ALL -> RepeatMode.ONE
RepeatMode.ONE -> RepeatMode.OFF
}
+ _repeatMode.value = next
+ viewModelScope.launch { settings.setRepeatModeName(next.name) }
}
fun toggleShuffle() {
val newValue = !_shuffle.value
_shuffle.value = newValue
+ viewModelScope.launch { settings.setShuffle(newValue) }
if (newValue) {
// Seed the shuffle around whatever's currently playing (if anything).
rebuildShuffleOrder(startId = playbackState.value.nowPlayingId)