feat: Add tap tempo
Tapping the TAP button records timestamps and calculates BPM from the average interval between the last few taps (within a 3 second window). After 2 seconds of no tapping the window resets. BPM is clamped to the 40-200 range and immediately applied to the running service if active. The TAP button sits to the left of START/STOP in a shared row, taking 1/3 of the width; START/STOP takes the remaining 2/3.
This commit is contained in:
parent
468657c374
commit
22c615f44d
2 changed files with 76 additions and 23 deletions
|
|
@ -1,18 +1,27 @@
|
|||
package net.jeena.pacer
|
||||
|
||||
import android.app.Application
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class PacerViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val ctx = application.applicationContext
|
||||
private val settings = SettingsRepository(ctx)
|
||||
|
||||
private val tapTimes = mutableListOf<Long>()
|
||||
private var tapResetJob: Job? = null
|
||||
|
||||
var bpm by mutableIntStateOf(settings.loadBpm())
|
||||
private set
|
||||
|
||||
|
|
@ -43,4 +52,21 @@ class PacerViewModel(application: Application) : AndroidViewModel(application) {
|
|||
settings.saveVolume(newVolume)
|
||||
if (isPlaying) ctx.startService(PacerService.updateVolumeIntent(ctx, newVolume))
|
||||
}
|
||||
|
||||
fun onTap() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
tapTimes.removeAll { now - it > 3000 }
|
||||
tapTimes.add(now)
|
||||
|
||||
if (tapTimes.size >= 2) {
|
||||
val avgInterval = tapTimes.zipWithNext { a, b -> b - a }.average()
|
||||
updateBpm((60000.0 / avgInterval).roundToInt().coerceIn(40, 200))
|
||||
}
|
||||
|
||||
tapResetJob?.cancel()
|
||||
tapResetJob = viewModelScope.launch {
|
||||
delay(2000)
|
||||
tapTimes.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -125,7 +127,8 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
|
|||
dotScale = dotScale.value,
|
||||
onBpmChange = { viewModel.updateBpm(it) },
|
||||
onVolChange = { viewModel.updateVolume(it) },
|
||||
onToggle = { viewModel.togglePlayback() }
|
||||
onToggle = { viewModel.togglePlayback() },
|
||||
onTap = { viewModel.onTap() }
|
||||
)
|
||||
} else {
|
||||
PortraitLayout(
|
||||
|
|
@ -139,7 +142,8 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
|
|||
dotScale = dotScale.value,
|
||||
onBpmChange = { viewModel.updateBpm(it) },
|
||||
onVolChange = { viewModel.updateVolume(it) },
|
||||
onToggle = { viewModel.togglePlayback() }
|
||||
onToggle = { viewModel.togglePlayback() },
|
||||
onTap = { viewModel.onTap() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +185,7 @@ private fun PortraitLayout(
|
|||
onBpmChange: (Int) -> Unit,
|
||||
onVolChange: (Float) -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -195,7 +200,7 @@ private fun PortraitLayout(
|
|||
PulseRing(rippleScale, rippleAlpha, dotScale, size = 80.dp)
|
||||
BpmControls(bpm, onBpmChange)
|
||||
VolumeControl(volume, onVolChange)
|
||||
StartStopButton(isPlaying, onToggle, Modifier.fillMaxWidth())
|
||||
BottomButtons(isPlaying, onToggle, onTap)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,6 +217,7 @@ private fun LandscapeLayout(
|
|||
onBpmChange: (Int) -> Unit,
|
||||
onVolChange: (Float) -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -242,7 +248,7 @@ private fun LandscapeLayout(
|
|||
) {
|
||||
BpmControls(bpm, onBpmChange)
|
||||
VolumeControl(volume, onVolChange)
|
||||
StartStopButton(isPlaying, onToggle, Modifier.fillMaxWidth())
|
||||
BottomButtons(isPlaying, onToggle, onTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -416,24 +422,45 @@ private fun VolumeControl(volume: Float, onVolChange: (Float) -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun StartStopButton(isPlaying: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) {
|
||||
OutlinedButton(
|
||||
onClick = onToggle,
|
||||
modifier = modifier,
|
||||
shape = RectangleShape,
|
||||
border = BorderStroke(2.dp, if (isPlaying) ACCENT else FG),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = if (isPlaying) BG else FG,
|
||||
containerColor = if (isPlaying) ACCENT else Color.Transparent
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = if (isPlaying) "STOP" else "START",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = JetBrainsMono,
|
||||
letterSpacing = 3.sp,
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
)
|
||||
private fun BottomButtons(isPlaying: Boolean, onToggle: () -> Unit, onTap: () -> Unit) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = onTap,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RectangleShape,
|
||||
border = BorderStroke(2.dp, DIM),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = FG,
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "TAP",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = JetBrainsMono,
|
||||
letterSpacing = 3.sp,
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onToggle,
|
||||
modifier = Modifier.weight(2f),
|
||||
shape = RectangleShape,
|
||||
border = BorderStroke(2.dp, if (isPlaying) ACCENT else FG),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = if (isPlaying) BG else FG,
|
||||
containerColor = if (isPlaying) ACCENT else Color.Transparent
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = if (isPlaying) "STOP" else "START",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = JetBrainsMono,
|
||||
letterSpacing = 3.sp,
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue