Polished Mini Player

This commit is contained in:
OmniaX-Dev 2026-06-23 14:03:16 +02:00
parent 7118596e1c
commit c5101b3708
5 changed files with 233 additions and 50 deletions

View file

@ -5,6 +5,8 @@
<map> <map>
<entry key="20260623-075402-8a5368e9-a150-4048-aaa6-a07699e3c266" value="true" /> <entry key="20260623-075402-8a5368e9-a150-4048-aaa6-a07699e3c266" value="true" />
<entry key="20260623-091803-ac498ab1-c280-469b-a10f-4e7d5a828aea" value="true" /> <entry key="20260623-091803-ac498ab1-c280-469b-a10f-4e7d5a828aea" value="true" />
<entry key="20260623-132846-a16e70c9-6749-49d9-8ddc-0e2751e51684" value="true" />
<entry key="20260623-133006-ee14cdd8-d70c-4923-afa5-55b94086b8f0" value="true" />
</map> </map>
</option> </option>
</component> </component>

View file

@ -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<Preferences> 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 }
}
}

View file

@ -1,6 +1,8 @@
package com.keylightpiano.pianotimeline.ui package com.keylightpiano.pianotimeline.ui
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -29,6 +31,7 @@ import androidx.compose.runtime.setValue
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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -47,6 +50,8 @@ fun MiniPlayer(
playback: PlaybackState, playback: PlaybackState,
onTogglePlayPause: () -> Unit, onTogglePlayPause: () -> Unit,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -66,6 +71,12 @@ fun MiniPlayer(
val sliderValue = if (isDragging) dragValueMs val sliderValue = if (isDragging) dragValueMs
else playback.positionMs.toFloat().coerceIn(0f, duration.toFloat()) else playback.positionMs.toFloat().coerceIn(0f, duration.toFloat())
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Slider( Slider(
value = sliderValue, value = sliderValue,
onValueChange = { v -> onValueChange = { v ->
@ -78,15 +89,15 @@ fun MiniPlayer(
}, },
valueRange = 0f..duration.toFloat(), valueRange = 0f..duration.toFloat(),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(horizontal = 2.dp), .padding(end = 8.dp),
thumb = { thumb = {
// Wider than tall, rounded — a little "pill" instead of a circle. // Wider than tall, rounded — a little "pill" instead of a circle.
Box( Box(
modifier = Modifier modifier = Modifier
.size(width = 12.dp, height = 13.dp) .size(width = 12.dp, height = 13.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.background(MaterialTheme.colorScheme.primary) .background(Color(0xFF23A300))
) )
}, },
track = { sliderState -> track = { sliderState ->
@ -98,7 +109,7 @@ fun MiniPlayer(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(4.dp) // ~half the default 4dp track .height(2.dp) // ~half the default 4dp track
) { ) {
// Inactive (full width, dim) // Inactive (full width, dim)
Box( Box(
@ -114,18 +125,35 @@ fun MiniPlayer(
.fillMaxWidth(fraction.coerceIn(0f, 1f)) .fillMaxWidth(fraction.coerceIn(0f, 1f))
.height(2.dp) .height(2.dp)
.clip(RoundedCornerShape(1.dp)) .clip(RoundedCornerShape(1.dp))
.background(MaterialTheme.colorScheme.primary) .background(Color(0xFF25B300))
) )
} }
} }
) )
Text(
text = "${formatTime(playback.positionMs)} / ${formatTime(playback.durationMs)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically 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)) { IconButton(onClick = onTogglePlayPause, modifier = Modifier.size(44.dp)) {
Icon( Icon(
painter = painterResource( 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)) Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = recording.artist, text = recording.artist,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = Color(0xFF25B300),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@ -152,10 +188,17 @@ fun MiniPlayer(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, 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)) { IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) {
Icon( Icon(
imageVector = Icons.Default.Close, 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)
}

View file

@ -186,6 +186,8 @@ fun RecordingListScreen(
playback = playbackState, playback = playbackState,
onTogglePlayPause = { vm.togglePlayPause() }, onTogglePlayPause = { vm.togglePlayPause() },
onSeek = { vm.seekTo(it) }, onSeek = { vm.seekTo(it) },
onPrevious = { vm.skipPrevious() },
onNext = { vm.skipNext() },
onDismiss = { vm.stopPlayback() } onDismiss = { vm.stopPlayback() }
) )
} }

View file

@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.keylightpiano.pianotimeline.data.MusicRepository import com.keylightpiano.pianotimeline.data.MusicRepository
import com.keylightpiano.pianotimeline.data.SettingsStore
import com.keylightpiano.pianotimeline.data.SyncManager import com.keylightpiano.pianotimeline.data.SyncManager
import com.keylightpiano.pianotimeline.domain.Recording import com.keylightpiano.pianotimeline.domain.Recording
import com.keylightpiano.pianotimeline.player.PlaybackState import com.keylightpiano.pianotimeline.player.PlaybackState
@ -28,6 +29,7 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
private val repo = MusicRepository(app) private val repo = MusicRepository(app)
private val syncManager = SyncManager(app) private val syncManager = SyncManager(app)
private val settings = SettingsStore(app)
private val playerManager = PlayerManager(app, viewModelScope, onTrackEnded = { advanceToNext() }) private val playerManager = PlayerManager(app, viewModelScope, onTrackEnded = { advanceToNext() })
/** Playback state the UI observes (which card is playing, position, etc). */ /** 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: run sync on creation ─────────────────────────────────────
init { 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 { viewModelScope.launch {
_syncState.value = SyncState.Syncing _syncState.value = SyncState.Syncing
val result = syncManager.sync() val result = syncManager.sync()
@ -180,19 +190,83 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
fun stopPlayback() = playerManager.stop() 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>): 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>): 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 ────────────────────────────────────── // ── Repeat / shuffle controls ──────────────────────────────────────
fun cycleRepeatMode() { fun cycleRepeatMode() {
_repeatMode.value = when (_repeatMode.value) { val next = when (_repeatMode.value) {
RepeatMode.OFF -> RepeatMode.ALL RepeatMode.OFF -> RepeatMode.ALL
RepeatMode.ALL -> RepeatMode.ONE RepeatMode.ALL -> RepeatMode.ONE
RepeatMode.ONE -> RepeatMode.OFF RepeatMode.ONE -> RepeatMode.OFF
} }
_repeatMode.value = next
viewModelScope.launch { settings.setRepeatModeName(next.name) }
} }
fun toggleShuffle() { fun toggleShuffle() {
val newValue = !_shuffle.value val newValue = !_shuffle.value
_shuffle.value = newValue _shuffle.value = newValue
viewModelScope.launch { settings.setShuffle(newValue) }
if (newValue) { if (newValue) {
// Seed the shuffle around whatever's currently playing (if anything). // Seed the shuffle around whatever's currently playing (if anything).
rebuildShuffleOrder(startId = playbackState.value.nowPlayingId) rebuildShuffleOrder(startId = playbackState.value.nowPlayingId)