Polished Mini Player
This commit is contained in:
parent
7118596e1c
commit
c5101b3708
5 changed files with 233 additions and 50 deletions
|
|
@ -5,6 +5,8 @@
|
|||
<map>
|
||||
<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-132846-a16e70c9-6749-49d9-8ddc-0e2751e51684" value="true" />
|
||||
<entry key="20260623-133006-ee14cdd8-d70c-4923-afa5-55b94086b8f0" value="true" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ fun RecordingListScreen(
|
|||
playback = playbackState,
|
||||
onTogglePlayPause = { vm.togglePlayPause() },
|
||||
onSeek = { vm.seekTo(it) },
|
||||
onPrevious = { vm.skipPrevious() },
|
||||
onNext = { vm.skipNext() },
|
||||
onDismiss = { vm.stopPlayback() }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>): 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 ──────────────────────────────────────
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue