diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt index 5e4fd81..b432b44 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/SettingsStore.kt @@ -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) + } + } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt b/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt index 3182f9c..5ee5d8e 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/player/PlayerManager.kt @@ -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() } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt index 94fbd33..585531d 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/MiniPlayer.kt @@ -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) ) } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt index 346f7fb..d391042 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingItem.kt @@ -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) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt index 4f0e0b5..b03859d 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListScreen.kt @@ -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) } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt index 49e511f..c500bc0 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/ui/RecordingListViewModel.kt @@ -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 ─────────────────────────────────────────────