Added fixed player
This commit is contained in:
parent
b195ef940f
commit
cae8f99830
6 changed files with 126 additions and 31 deletions
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -26,6 +27,8 @@ class SettingsStore(private val context: Context) {
|
||||||
private companion object {
|
private companion object {
|
||||||
val KEY_SHUFFLE = booleanPreferencesKey("shuffle_enabled")
|
val KEY_SHUFFLE = booleanPreferencesKey("shuffle_enabled")
|
||||||
val KEY_REPEAT = stringPreferencesKey("repeat_mode")
|
val KEY_REPEAT = stringPreferencesKey("repeat_mode")
|
||||||
|
val KEY_LAST_TRACK_ID = stringPreferencesKey("last_track_id")
|
||||||
|
val KEY_LAST_POSITION_MS = longPreferencesKey("last_position_ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the stored shuffle flag (default false). */
|
/** Read the stored shuffle flag (default false). */
|
||||||
|
|
@ -51,4 +54,35 @@ class SettingsStore(private val context: Context) {
|
||||||
suspend fun setRepeatModeName(name: String) {
|
suspend fun setRepeatModeName(name: String) {
|
||||||
context.userPrefsStore.edit { it[KEY_REPEAT] = name }
|
context.userPrefsStore.edit { it[KEY_REPEAT] = name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-played track id + position, so the player can restore (paused) on
|
||||||
|
* next launch. A null/absent id means "nothing to restore" (fresh install or
|
||||||
|
* after the track was stopped).
|
||||||
|
*/
|
||||||
|
suspend fun getLastTrackId(): String? {
|
||||||
|
val prefs = context.userPrefsStore.data.first()
|
||||||
|
return prefs[KEY_LAST_TRACK_ID]
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLastPositionMs(): Long {
|
||||||
|
val prefs = context.userPrefsStore.data.first()
|
||||||
|
return prefs[KEY_LAST_POSITION_MS] ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the currently-playing track and offset. */
|
||||||
|
suspend fun setLastPlayed(trackId: String, positionMs: Long) {
|
||||||
|
context.userPrefsStore.edit {
|
||||||
|
it[KEY_LAST_TRACK_ID] = trackId
|
||||||
|
it[KEY_LAST_POSITION_MS] = positionMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the remembered track (called when playback is stopped). */
|
||||||
|
suspend fun clearLastPlayed() {
|
||||||
|
context.userPrefsStore.edit {
|
||||||
|
it.remove(KEY_LAST_TRACK_ID)
|
||||||
|
it.remove(KEY_LAST_POSITION_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,28 @@ class PlayerManager(
|
||||||
player.pause()
|
player.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a track and seek to [positionMs] but do NOT start playing — used to
|
||||||
|
* restore the last session on launch, Spotify-style (comes back paused).
|
||||||
|
* ExoPlayer defers the seek until the item is prepared enough to honor it,
|
||||||
|
* so calling prepare() then seekTo() before playback is safe.
|
||||||
|
*/
|
||||||
|
fun preparePaused(recordingId: String, url: String, positionMs: Long) {
|
||||||
|
currentId = recordingId
|
||||||
|
player.setMediaItem(MediaItem.fromUri(url))
|
||||||
|
player.prepare()
|
||||||
|
player.seekTo(positionMs.coerceAtLeast(0L))
|
||||||
|
// Deliberately no play(). Reflect the restored position right away so the
|
||||||
|
// mini-player renders at the correct spot before ExoPlayer reports ready.
|
||||||
|
_state.value = PlaybackState(
|
||||||
|
nowPlayingId = recordingId,
|
||||||
|
isPlaying = false,
|
||||||
|
isBuffering = false,
|
||||||
|
positionMs = positionMs.coerceAtLeast(0L),
|
||||||
|
durationMs = 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun resume() {
|
fun resume() {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.material.icons.filled.RepeatOne
|
import androidx.compose.material.icons.filled.RepeatOne
|
||||||
import androidx.compose.material.icons.filled.Shuffle
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -54,13 +54,13 @@ import com.keylightpiano.pianotimeline.player.PlaybackState
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MiniPlayer(
|
fun MiniPlayer(
|
||||||
recording: Recording,
|
recording: Recording?,
|
||||||
playback: PlaybackState,
|
playback: PlaybackState,
|
||||||
onTogglePlayPause: () -> Unit,
|
onTogglePlayPause: () -> Unit,
|
||||||
onSeek: (Long) -> Unit,
|
onSeek: (Long) -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onStop: () -> Unit,
|
||||||
shuffle: Boolean,
|
shuffle: Boolean,
|
||||||
repeatMode: RepeatMode,
|
repeatMode: RepeatMode,
|
||||||
onToggleShuffle: () -> Unit,
|
onToggleShuffle: () -> Unit,
|
||||||
|
|
@ -86,6 +86,10 @@ fun MiniPlayer(
|
||||||
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
|
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
else Modifier
|
else Modifier
|
||||||
) {
|
) {
|
||||||
|
// Stopped = nothing loaded (fresh start, or after Stop). The bar stays
|
||||||
|
// visible but shows an empty/reset state with controls inert.
|
||||||
|
val stopped = recording == null
|
||||||
|
|
||||||
// While the user is dragging, we hold a local position so the thumb
|
// While the user is dragging, we hold a local position so the thumb
|
||||||
// follows the finger instead of snapping back to the polled playhead.
|
// follows the finger instead of snapping back to the polled playhead.
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
|
|
@ -127,6 +131,7 @@ fun MiniPlayer(
|
||||||
|
|
||||||
Slider(
|
Slider(
|
||||||
value = sliderValue,
|
value = sliderValue,
|
||||||
|
enabled = !stopped,
|
||||||
onValueChange = { v ->
|
onValueChange = { v ->
|
||||||
isDragging = true
|
isDragging = true
|
||||||
dragValueMs = v
|
dragValueMs = v
|
||||||
|
|
@ -194,7 +199,7 @@ fun MiniPlayer(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
||||||
IconButton(onClick = onPrevious, modifier = Modifier.size(40.dp)) {
|
IconButton(onClick = onPrevious, enabled = !stopped, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(android.R.drawable.ic_media_previous),
|
painter = painterResource(android.R.drawable.ic_media_previous),
|
||||||
contentDescription = stringResource(R.string.cd_previous),
|
contentDescription = stringResource(R.string.cd_previous),
|
||||||
|
|
@ -202,7 +207,7 @@ fun MiniPlayer(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = onTogglePlayPause, modifier = Modifier.size(44.dp)) {
|
IconButton(onClick = onTogglePlayPause, enabled = !stopped, modifier = Modifier.size(44.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(
|
painter = painterResource(
|
||||||
if (playback.isPlaying) android.R.drawable.ic_media_pause
|
if (playback.isPlaying) android.R.drawable.ic_media_pause
|
||||||
|
|
@ -213,7 +218,7 @@ fun MiniPlayer(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = onNext, modifier = Modifier.size(40.dp)) {
|
IconButton(onClick = onNext, enabled = !stopped, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(android.R.drawable.ic_media_next),
|
painter = painterResource(android.R.drawable.ic_media_next),
|
||||||
contentDescription = stringResource(R.string.cd_next),
|
contentDescription = stringResource(R.string.cd_next),
|
||||||
|
|
@ -225,14 +230,14 @@ fun MiniPlayer(
|
||||||
|
|
||||||
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 = Color(0xFF25B300),
|
color = Color(0xFF25B300),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = recording.title,
|
text = recording?.title ?: "",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
@ -247,11 +252,11 @@ fun MiniPlayer(
|
||||||
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
||||||
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) {
|
IconButton(onClick = onStop, enabled = !stopped, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Stop,
|
||||||
contentDescription = stringResource(R.string.cd_stop),
|
contentDescription = stringResource(R.string.cd_stop),
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ fun RecordingItem(
|
||||||
// file exists; disabled with a "blocked" icon otherwise.
|
// file exists; disabled with a "blocked" icon otherwise.
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = { onNotesClick?.invoke() },
|
onClick = { onNotesClick?.invoke() },
|
||||||
enabled = hasMidi,
|
enabled = hasMidi && !isDisabledOffline,
|
||||||
modifier = Modifier.size(44.dp),
|
modifier = Modifier.size(44.dp),
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = if (hasMidi) NotesGreen.copy(alpha = 0.18f)
|
containerColor = if (hasMidi) NotesGreen.copy(alpha = 0.18f)
|
||||||
|
|
|
||||||
|
|
@ -219,24 +219,22 @@ fun RecordingListScreen(
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Column {
|
Column {
|
||||||
if (nowPlaying != null) {
|
MiniPlayer(
|
||||||
MiniPlayer(
|
recording = nowPlaying, // null = stopped/empty state
|
||||||
recording = nowPlaying,
|
playback = playbackState,
|
||||||
playback = playbackState,
|
onTogglePlayPause = { vm.togglePlayPause() },
|
||||||
onTogglePlayPause = { vm.togglePlayPause() },
|
onSeek = { vm.seekTo(it) },
|
||||||
onSeek = { vm.seekTo(it) },
|
onPrevious = { vm.skipPrevious() },
|
||||||
onPrevious = { vm.skipPrevious() },
|
onNext = { vm.skipNext() },
|
||||||
onNext = { vm.skipNext() },
|
onStop = { vm.stopPlayback() },
|
||||||
onDismiss = { vm.stopPlayback() },
|
shuffle = shuffle,
|
||||||
shuffle = shuffle,
|
repeatMode = repeatMode,
|
||||||
repeatMode = repeatMode,
|
onToggleShuffle = { vm.toggleShuffle() },
|
||||||
onToggleShuffle = { vm.toggleShuffle() },
|
onCycleRepeat = { vm.cycleRepeatMode() },
|
||||||
onCycleRepeat = { vm.cycleRepeatMode() },
|
// When the offline banner is showing below, IT owns the
|
||||||
// When the offline banner is showing below, IT owns the
|
// nav-bar inset, so the mini-player must not also apply it.
|
||||||
// nav-bar inset, so the mini-player must not also apply it.
|
applyNavigationBarInset = isOnline
|
||||||
applyNavigationBarInset = isOnline
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
OfflineBanner(visible = !isOnline)
|
OfflineBanner(visible = !isOnline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
@ -211,6 +212,38 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
}.getOrDefault(RepeatMode.OFF)
|
}.getOrDefault(RepeatMode.OFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the last-played track (PAUSED, Spotify-style) once the list is
|
||||||
|
// available so we can resolve the id → URL. Fires only once.
|
||||||
|
viewModelScope.launch {
|
||||||
|
val savedId = settings.getLastTrackId() ?: return@launch
|
||||||
|
val savedPos = settings.getLastPositionMs()
|
||||||
|
// Wait for the first non-empty list, then match the saved id against it.
|
||||||
|
val list = recordings.first { it.isNotEmpty() }
|
||||||
|
val match = list.firstOrNull { it.id == savedId }
|
||||||
|
if (match != null) {
|
||||||
|
playerManager.preparePaused(match.id, repo.audioUrl(match), savedPos)
|
||||||
|
} else {
|
||||||
|
// Track no longer exists (removed on the server, or storage cleared)
|
||||||
|
// — drop the stale pointer so we don't keep trying.
|
||||||
|
settings.clearLastPlayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled persistence of the current track + offset: at most once per
|
||||||
|
// second, and only while a track is loaded. Saving live (every 200ms
|
||||||
|
// position tick) would hammer DataStore; 1s is plenty for resume.
|
||||||
|
viewModelScope.launch {
|
||||||
|
var lastSaveAt = 0L
|
||||||
|
playbackState.collect { s ->
|
||||||
|
val id = s.nowPlayingId ?: return@collect
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastSaveAt >= 1000L) {
|
||||||
|
lastSaveAt = now
|
||||||
|
settings.setLastPlayed(id, s.positionMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When recordings become available, flush any deep link that arrived
|
// When recordings become available, flush any deep link that arrived
|
||||||
// before the list was ready.
|
// before the list was ready.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -317,7 +350,10 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) = playerManager.seekTo(positionMs)
|
fun seekTo(positionMs: Long) = playerManager.seekTo(positionMs)
|
||||||
|
|
||||||
fun stopPlayback() = playerManager.stop()
|
fun stopPlayback() {
|
||||||
|
playerManager.stop()
|
||||||
|
viewModelScope.launch { settings.clearLastPlayed() }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Deep-link handling ─────────────────────────────────────────────
|
// ── Deep-link handling ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue