Added fixed player

This commit is contained in:
OmniaX-Dev 2026-07-05 06:05:53 +02:00
parent b195ef940f
commit cae8f99830
6 changed files with 126 additions and 31 deletions

View file

@ -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)
}
}
}

View file

@ -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()
}

View file

@ -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)
)
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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 ─────────────────────────────────────────────