From c5101b3708f3b073692922b75cce2572952a4a48 Mon Sep 17 00:00:00 2001 From: OmniaX-Dev Date: Tue, 23 Jun 2026 14:03:16 +0200 Subject: [PATCH] Polished Mini Player --- .idea/planningMode.xml | 2 + .../pianotimeline/data/SettingsStore.kt | 54 +++++++ .../pianotimeline/ui/MiniPlayer.kt | 149 ++++++++++++------ .../pianotimeline/ui/RecordingListScreen.kt | 2 + .../ui/RecordingListViewModel.kt | 76 ++++++++- 5 files changed, 233 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt 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)