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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue