201 lines
7.9 KiB
Kotlin
201 lines
7.9 KiB
Kotlin
|
|
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
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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<Long>() }
|
||
|
|
|
||
|
|
var playlistDisplayName by remember { mutableStateOf("playlist") }
|
||
|
|
|
||
|
|
LaunchedEffect(playlistId) {
|
||
|
|
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||
|
|
}
|
||
|
|
|
||
|
|
LaunchedEffect(playback, playlistId, playlistDisplayName) {
|
||
|
|
val service = playback ?: return@LaunchedEffect
|
||
|
|
service.trackBoundaryEvents.collect { event ->
|
||
|
|
val snapshot = beatTimesMs.toList()
|
||
|
|
beatTimesMs.clear()
|
||
|
|
viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, 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,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|