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:
Jeena 2026-03-10 05:59:46 +00:00
parent 468657c374
commit 22c615f44d
2 changed files with 76 additions and 23 deletions

View file

@ -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()
}
}
}

View file

@ -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)
)
}
}
}