package at.lockstep.player.ui import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.BackHandler 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 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 import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf 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 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 import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import at.lockstep.player.LockstepViewModel import at.lockstep.player.playback.PlaybackService import at.lockstep.player.R import at.lockstep.player.util.RunDataCollector import at.lockstep.player.util.RunDataStorage data class NowPlayingUiState( val title: String, val artist: String, /** 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( modifier = modifier .fillMaxSize() .padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { 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") } } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun NowPlayingRoute( playlistId: String, playback: PlaybackService?, viewModel: LockstepViewModel, onBack: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current BackHandler(onBack = onBack) 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(null) } var currentQueueIndex by remember { mutableIntStateOf(0) } 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, ), ) } LaunchedEffect(playlistId) { playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) } LaunchedEffect(playlistId, playlistDisplayName) { viewModel.fetchBeatsMetadataForPlaylist(playlistId, playlistDisplayName) } LaunchedEffect(playback) { val service = playback ?: return@LaunchedEffect service.uiState.collect { p -> currentTrackId = p.currentTrackId currentQueueIndex = p.currentQueueIndex 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, ) } } LaunchedEffect(collectRunData, playback) { if (!collectRunData) { collector.setCollectingEnabled(false) return@LaunchedEffect } val service = playback ?: run { collector.setCollectingEnabled(false) return@LaunchedEffect } collector.setPlaybackPositionMsProvider { service.getPlaybackPositionMs() } collector.setCollectingEnabled(true) 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, playlistDisplayName) { if (!collectRunData) return@LaunchedEffect val service = playback ?: return@LaunchedEffect service.trackBoundaryEvents.collect { event -> val snapshot = collector.snapshotAndClear() viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot) } } 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, queueIndex = currentQueueIndex, snapshot = snapshot, ) } } collector.release() } } 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( state = NowPlayingUiState( title = "Example Track Title", artist = "Example Artist", stepFrequencyDisplay = "--", progress = progress, durationSeconds = 215, isPlaying = playing, ), onProgressChange = { progress = it }, onPrevious = {}, onTogglePlayPause = { playing = !playing }, onNext = {}, ) } }