Files
lockstep-player/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt

354 lines
13 KiB
Kotlin
Raw Normal View History

package at.lockstep.player.ui
2026-05-24 07:17:51 +02:00
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
2026-05-24 07:17:51 +02:00
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
2026-05-14 02:43:49 +02:00
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
2026-05-14 02:43:49 +02:00
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
2026-05-14 02:43:49 +02:00
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
2026-05-14 02:43:49 +02:00
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
2026-05-24 07:17:51 +02:00
import androidx.compose.runtime.DisposableEffect
2026-05-14 02:43:49 +02:00
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
2026-05-24 07:17:51 +02:00
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
2026-05-14 02:43:49 +02:00
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
2026-05-24 07:17:51 +02:00
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.lockstep.player.LockstepViewModel
2026-05-14 02:43:49 +02:00
import at.lockstep.player.playback.PlaybackService
import at.lockstep.player.R
2026-05-24 07:17:51 +02:00
import at.lockstep.player.util.RunDataCollector
import at.lockstep.player.util.RunDataStorage
data class NowPlayingUiState(
val title: String,
val artist: String,
2026-05-14 02:43:49 +02:00
/** Placeholder until live cadence / JNI wiring; MVP shows "--". */
val stepFrequencyDisplay: String,
/** 0f..1f until real timing is wired to Oboe / JNI. */
val progress: Float,
/** Total track length in seconds (placeholder for UI formatting). */
val durationSeconds: Int,
val isPlaying: Boolean,
)
@Composable
fun NowPlayingScreen(
state: NowPlayingUiState,
onProgressChange: (Float) -> Unit,
onPrevious: () -> Unit,
onTogglePlayPause: () -> Unit,
onNext: () -> Unit,
modifier: Modifier = Modifier,
) {
val elapsed = (state.progress * state.durationSeconds).toInt().coerceIn(0, state.durationSeconds)
Column(
2026-05-14 02:43:49 +02:00
modifier =
modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
2026-05-14 02:43:49 +02:00
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = LocalContext.current.getString(R.string.now_playing_step_frequency_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = state.stepFrequencyDisplay,
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = state.title,
style = MaterialTheme.typography.headlineSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = state.artist,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Slider(
value = state.progress,
onValueChange = onProgressChange,
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = formatMmSs(elapsed),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatMmSs(state.durationSeconds),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onPrevious, modifier = Modifier.size(80.dp)) {
Icon(Icons.Default.SkipPrevious, contentDescription = "Previous track")
}
IconButton(onClick = onTogglePlayPause, modifier = Modifier.size(104.dp)) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Play",
modifier = Modifier.size(56.dp),
)
}
IconButton(onClick = onNext, modifier = Modifier.size(80.dp)) {
Icon(Icons.Default.SkipNext, contentDescription = "Next track")
}
}
}
}
}
2026-05-14 02:43:49 +02:00
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NowPlayingRoute(
playlistId: String,
playback: PlaybackService?,
2026-05-24 07:17:51 +02:00
viewModel: LockstepViewModel,
2026-05-14 02:43:49 +02:00
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
BackHandler(onBack = onBack)
2026-05-24 07:17:51 +02:00
val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle()
val collector = remember { RunDataCollector(context) }
val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
val locationPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
if (collectRunData) {
collector.start(enableLocation = granted)
}
},
)
fun startRunDataCollection() {
val hasLocation =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!hasLocation) {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
collector.start(enableLocation = hasLocation)
}
var playlistDisplayName by remember { mutableStateOf("playlist") }
var currentTrackId by remember { mutableStateOf<String?>(null) }
var currentPlaylistPosition by remember { mutableIntStateOf(0) }
2026-05-24 07:17:51 +02:00
2026-05-14 02:43:49 +02:00
var ui by remember {
mutableStateOf(
NowPlayingUiState(
title = context.getString(R.string.now_playing_idle_title),
artist = context.getString(R.string.now_playing_idle_artist),
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
progress = 0f,
durationSeconds = 180,
isPlaying = false,
),
)
}
2026-05-24 07:17:51 +02:00
LaunchedEffect(playlistId) {
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
viewModel.fetchBeatsMetadataForPlaylist(playlistId, name)
2026-05-31 11:41:06 +02:00
}
2026-05-14 02:43:49 +02:00
LaunchedEffect(playback) {
val service = playback ?: return@LaunchedEffect
service.uiState.collect { p ->
2026-05-24 07:17:51 +02:00
currentTrackId = p.currentTrackId
currentPlaylistPosition = p.currentPlaylistPosition
2026-05-14 02:43:49 +02:00
ui =
NowPlayingUiState(
title = p.title,
artist = p.artist,
stepFrequencyDisplay = context.getString(R.string.step_frequency_placeholder),
progress = p.progress,
durationSeconds = p.durationSeconds,
isPlaying = p.isPlaying,
)
}
}
2026-05-24 07:17:51 +02:00
LaunchedEffect(collectRunData, playback) {
if (!collectRunData) {
collector.setCollectingEnabled(false)
2026-05-24 07:17:51 +02:00
return@LaunchedEffect
}
val service = playback ?: run {
collector.setCollectingEnabled(false)
return@LaunchedEffect
}
collector.setPlaybackPositionMsProvider { service.getPlaybackPositionMs() }
collector.setCollectingEnabled(true)
2026-05-24 07:17:51 +02:00
var lastTrackId: String? = null
service.uiState.collect { state ->
val trackId = state.currentTrackId
if (trackId != null && trackId != lastTrackId) {
collector.markSongStart()
lastTrackId = trackId
}
}
}
LaunchedEffect(collectRunData, playback, playlistId) {
2026-05-24 07:17:51 +02:00
if (!collectRunData) return@LaunchedEffect
val service = playback ?: return@LaunchedEffect
val name = viewModel.getPlaylistDisplayName(playlistId)
playlistDisplayName = name
2026-05-24 07:17:51 +02:00
service.trackBoundaryEvents.collect { event ->
val snapshot = collector.snapshotAndClear()
viewModel.persistRunData(playlistId, name, runSessionFolder, event, snapshot)
2026-05-24 07:17:51 +02:00
}
}
DisposableEffect(collectRunData) {
if (collectRunData) {
startRunDataCollection()
} else {
collector.stop()
collector.snapshotAndClear()
}
onDispose {
if (collectRunData) {
val snapshot = collector.snapshotAndClear()
val trackId = currentTrackId
if (!snapshot.isEmpty() && trackId != null) {
viewModel.persistRunDataForCurrentTrack(
playlistId = playlistId,
playlistDisplayName = playlistDisplayName,
runSessionFolder = runSessionFolder,
trackId = trackId,
title = ui.title,
artist = ui.artist,
playlistPosition = currentPlaylistPosition,
2026-05-24 07:17:51 +02:00
snapshot = snapshot,
)
}
}
collector.release()
}
}
2026-05-14 02:43:49 +02:00
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(text = context.getString(R.string.now_playing_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
},
) { padding ->
NowPlayingScreen(
state = ui,
onProgressChange = { fraction ->
playback?.requestSeek(fraction)
ui = ui.copy(progress = fraction)
},
onPrevious = { playback?.requestSkipPrevious() },
onTogglePlayPause = { playback?.requestTogglePause() },
onNext = { playback?.requestSkipNext() },
modifier = Modifier.padding(padding),
)
}
}
private fun formatMmSs(totalSeconds: Int): String {
val s = totalSeconds.coerceAtLeast(0)
val m = s / 60
val r = s % 60
return "%d:%02d".format(m, r)
}
@Preview(showBackground = true)
@Composable
private fun NowPlayingScreenPreview() {
MaterialTheme {
var progress by remember { mutableFloatStateOf(0.35f) }
var playing by remember { mutableStateOf(true) }
NowPlayingScreen(
2026-05-14 02:43:49 +02:00
state =
NowPlayingUiState(
title = "Example Track Title",
artist = "Example Artist",
stepFrequencyDisplay = "--",
progress = progress,
durationSeconds = 215,
isPlaying = playing,
),
onProgressChange = { progress = it },
onPrevious = {},
onTogglePlayPause = { playing = !playing },
onNext = {},
)
}
}