2026-05-14 00:44:43 +02:00
|
|
|
package at.lockstep.player.ui
|
|
|
|
|
|
2026-05-24 07:17:51 +02:00
|
|
|
import android.Manifest
|
|
|
|
|
import android.content.pm.PackageManager
|
2026-05-31 11:53:20 +02:00
|
|
|
import androidx.activity.compose.BackHandler
|
2026-05-24 07:17:51 +02:00
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
|
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
2026-05-14 00:44:43 +02:00
|
|
|
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
|
2026-05-14 00:44:43 +02:00
|
|
|
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
|
2026-05-14 00:44:43 +02:00
|
|
|
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
|
2026-05-14 00:44:43 +02:00
|
|
|
import androidx.compose.material3.Slider
|
|
|
|
|
import androidx.compose.material3.Text
|
2026-05-14 02:43:49 +02:00
|
|
|
import androidx.compose.material3.TopAppBar
|
2026-05-14 00:44:43 +02:00
|
|
|
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
|
2026-05-14 00:44:43 +02:00
|
|
|
import androidx.compose.runtime.getValue
|
|
|
|
|
import androidx.compose.runtime.mutableFloatStateOf
|
2026-05-24 07:17:51 +02:00
|
|
|
import androidx.compose.runtime.mutableIntStateOf
|
2026-05-14 00:44:43 +02:00
|
|
|
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
|
2026-05-14 00:44:43 +02:00
|
|
|
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
|
2026-05-14 00:44:43 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-05-14 00:44:43 +02:00
|
|
|
/** 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),
|
2026-05-14 00:44:43 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 00:44:43 +02:00
|
|
|
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
|
2026-05-31 11:53:20 +02:00
|
|
|
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 currentQueueIndex by remember { mutableIntStateOf(0) }
|
|
|
|
|
|
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) {
|
2026-05-31 12:02:28 +02:00
|
|
|
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
|
|
|
|
|
currentQueueIndex = p.currentQueueIndex
|
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) {
|
2026-05-24 07:29:26 +02:00
|
|
|
collector.setCollectingEnabled(false)
|
2026-05-24 07:17:51 +02:00
|
|
|
return@LaunchedEffect
|
|
|
|
|
}
|
2026-05-24 07:29:26 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 12:02:28 +02:00
|
|
|
LaunchedEffect(collectRunData, playback, playlistId) {
|
2026-05-24 07:17:51 +02:00
|
|
|
if (!collectRunData) return@LaunchedEffect
|
|
|
|
|
val service = playback ?: return@LaunchedEffect
|
2026-05-31 12:02:28 +02:00
|
|
|
val name = viewModel.getPlaylistDisplayName(playlistId)
|
|
|
|
|
playlistDisplayName = name
|
2026-05-24 07:17:51 +02:00
|
|
|
service.trackBoundaryEvents.collect { event ->
|
|
|
|
|
val snapshot = collector.snapshotAndClear()
|
2026-05-31 12:02:28 +02:00
|
|
|
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,
|
2026-05-24 07:24:16 +02:00
|
|
|
title = ui.title,
|
|
|
|
|
artist = ui.artist,
|
2026-05-24 07:17:51 +02:00
|
|
|
queueIndex = currentQueueIndex,
|
|
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 00:44:43 +02:00
|
|
|
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,
|
|
|
|
|
),
|
2026-05-14 00:44:43 +02:00
|
|
|
onProgressChange = { progress = it },
|
|
|
|
|
onPrevious = {},
|
|
|
|
|
onTogglePlayPause = { playing = !playing },
|
|
|
|
|
onNext = {},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|