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

348 lines
13 KiB
Kotlin

package at.lockstep.player.ui
import android.Manifest
import android.content.pm.PackageManager
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
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) }
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(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 = {},
)
}
}