package at.lockstep.player.ui import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf 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.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import at.lockstep.player.LockstepViewModel import at.lockstep.player.R import at.lockstep.player.playback.PlaybackService import at.lockstep.player.util.RunDataStorage /** * Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player] * timeline). JSON is written only when the track changes (skip/prev/next or natural advance) or when * the last track in the playlist finishes — see [PlaybackService.trackBoundaryEvents]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AnnotationRoute( playlistId: String, playback: PlaybackService?, viewModel: LockstepViewModel, onBack: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current 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(playback) { val service = playback ?: return@LaunchedEffect service.uiState.collect { p -> 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, ) } } val beatTimesMs = remember { mutableStateListOf() } val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() } var playlistDisplayName by remember { mutableStateOf("playlist") } LaunchedEffect(playlistId) { playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) } LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) { val service = playback ?: return@LaunchedEffect service.trackBoundaryEvents.collect { event -> val snapshot = beatTimesMs.toList() beatTimesMs.clear() viewModel.persistBeatAnnotation( playlistId, playlistDisplayName, annotationSessionFolder, event, snapshot, ) } } Scaffold( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( title = { Text(text = context.getString(R.string.annotation_title)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } }, ) }, ) { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding), ) { Text( text = context.getString(R.string.annotation_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) Column( modifier = Modifier .fillMaxWidth() .heightIn(max = 420.dp) .padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { NowPlayingScreen( state = ui, onProgressChange = { fraction -> playback?.requestSeek(fraction) ui = ui.copy(progress = fraction) }, onPrevious = { playback?.requestSkipPrevious() }, onTogglePlayPause = { playback?.requestTogglePause() }, onNext = { playback?.requestSkipNext() }, modifier = Modifier.fillMaxWidth(), ) } Text( text = context.getString(R.string.annotation_beat_count, beatTimesMs.size), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), ) Box( modifier = Modifier .weight(1f) .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) .background(MaterialTheme.colorScheme.primaryContainer) .pointerInput(playback) { awaitEachGesture { awaitFirstDown(requireUnconsumed = false) val t = playback?.getPlaybackPositionMs() ?: 0L beatTimesMs.add(t) } }, contentAlignment = Alignment.Center, ) { Text( text = context.getString(R.string.annotation_tap_area_label), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onPrimaryContainer, ) } LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 160.dp) .padding(horizontal = 24.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { itemsIndexed(beatTimesMs) { i, ms -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = "${i + 1}.", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = context.getString(R.string.annotation_time_ms, ms.toInt()), style = MaterialTheme.typography.bodyLarge, ) } } } } } }