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.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
|
@ -26,6 +27,8 @@ class SettingsStore(private val context: Context) {
|
|||
private companion object {
|
||||
val KEY_SHUFFLE = booleanPreferencesKey("shuffle_enabled")
|
||||
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). */
|
||||
|
|
@ -51,4 +54,35 @@ class SettingsStore(private val context: Context) {
|
|||
suspend fun setRepeatModeName(name: String) {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
player.play()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.RepeatOne
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -54,13 +54,13 @@ import com.keylightpiano.pianotimeline.player.PlaybackState
|
|||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MiniPlayer(
|
||||
recording: Recording,
|
||||
recording: Recording?,
|
||||
playback: PlaybackState,
|
||||
onTogglePlayPause: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
shuffle: Boolean,
|
||||
repeatMode: RepeatMode,
|
||||
onToggleShuffle: () -> Unit,
|
||||
|
|
@ -86,6 +86,10 @@ fun MiniPlayer(
|
|||
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
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
|
||||
// follows the finger instead of snapping back to the polled playhead.
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
|
@ -127,6 +131,7 @@ fun MiniPlayer(
|
|||
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
enabled = !stopped,
|
||||
onValueChange = { v ->
|
||||
isDragging = true
|
||||
dragValueMs = v
|
||||
|
|
@ -194,7 +199,7 @@ fun MiniPlayer(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
IconButton(onClick = onPrevious, modifier = Modifier.size(40.dp)) {
|
||||
IconButton(onClick = onPrevious, enabled = !stopped, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(android.R.drawable.ic_media_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(
|
||||
painter = painterResource(
|
||||
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(
|
||||
painter = painterResource(android.R.drawable.ic_media_next),
|
||||
contentDescription = stringResource(R.string.cd_next),
|
||||
|
|
@ -225,14 +230,14 @@ fun MiniPlayer(
|
|||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = recording.artist,
|
||||
text = recording?.artist ?: "",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFF25B300),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = recording.title,
|
||||
text = recording?.title ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
|
|
@ -247,11 +252,11 @@ fun MiniPlayer(
|
|||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
IconButton(onClick = onDismiss, modifier = Modifier.size(40.dp)) {
|
||||
IconButton(onClick = onStop, enabled = !stopped, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
imageVector = Icons.Default.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.
|
||||
FilledTonalIconButton(
|
||||
onClick = { onNotesClick?.invoke() },
|
||||
enabled = hasMidi,
|
||||
enabled = hasMidi && !isDisabledOffline,
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = if (hasMidi) NotesGreen.copy(alpha = 0.18f)
|
||||
|
|
|
|||
|
|
@ -219,24 +219,22 @@ fun RecordingListScreen(
|
|||
},
|
||||
bottomBar = {
|
||||
Column {
|
||||
if (nowPlaying != null) {
|
||||
MiniPlayer(
|
||||
recording = nowPlaying,
|
||||
playback = playbackState,
|
||||
onTogglePlayPause = { vm.togglePlayPause() },
|
||||
onSeek = { vm.seekTo(it) },
|
||||
onPrevious = { vm.skipPrevious() },
|
||||
onNext = { vm.skipNext() },
|
||||
onDismiss = { vm.stopPlayback() },
|
||||
shuffle = shuffle,
|
||||
repeatMode = repeatMode,
|
||||
onToggleShuffle = { vm.toggleShuffle() },
|
||||
onCycleRepeat = { vm.cycleRepeatMode() },
|
||||
// When the offline banner is showing below, IT owns the
|
||||
// nav-bar inset, so the mini-player must not also apply it.
|
||||
applyNavigationBarInset = isOnline
|
||||
)
|
||||
}
|
||||
MiniPlayer(
|
||||
recording = nowPlaying, // null = stopped/empty state
|
||||
playback = playbackState,
|
||||
onTogglePlayPause = { vm.togglePlayPause() },
|
||||
onSeek = { vm.seekTo(it) },
|
||||
onPrevious = { vm.skipPrevious() },
|
||||
onNext = { vm.skipNext() },
|
||||
onStop = { vm.stopPlayback() },
|
||||
shuffle = shuffle,
|
||||
repeatMode = repeatMode,
|
||||
onToggleShuffle = { vm.toggleShuffle() },
|
||||
onCycleRepeat = { vm.cycleRepeatMode() },
|
||||
// When the offline banner is showing below, IT owns the
|
||||
// nav-bar inset, so the mini-player must not also apply it.
|
||||
applyNavigationBarInset = isOnline
|
||||
)
|
||||
OfflineBanner(visible = !isOnline)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
|
@ -211,6 +212,38 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
}.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
|
||||
// before the list was ready.
|
||||
viewModelScope.launch {
|
||||
|
|
@ -317,7 +350,10 @@ class RecordingListViewModel(app: Application) : AndroidViewModel(app) {
|
|||
|
||||
fun seekTo(positionMs: Long) = playerManager.seekTo(positionMs)
|
||||
|
||||
fun stopPlayback() = playerManager.stop()
|
||||
fun stopPlayback() {
|
||||
playerManager.stop()
|
||||
viewModelScope.launch { settings.clearLastPlayed() }
|
||||
}
|
||||
|
||||
// ── Deep-link handling ─────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue