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 package net.jeena.pacer
import android.app.Application import android.app.Application
import android.os.SystemClock
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel 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) { class PacerViewModel(application: Application) : AndroidViewModel(application) {
private val ctx = application.applicationContext private val ctx = application.applicationContext
private val settings = SettingsRepository(ctx) private val settings = SettingsRepository(ctx)
private val tapTimes = mutableListOf<Long>()
private var tapResetJob: Job? = null
var bpm by mutableIntStateOf(settings.loadBpm()) var bpm by mutableIntStateOf(settings.loadBpm())
private set private set
@ -43,4 +52,21 @@ class PacerViewModel(application: Application) : AndroidViewModel(application) {
settings.saveVolume(newVolume) settings.saveVolume(newVolume)
if (isPlaying) ctx.startService(PacerService.updateVolumeIntent(ctx, 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.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -125,7 +127,8 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
dotScale = dotScale.value, dotScale = dotScale.value,
onBpmChange = { viewModel.updateBpm(it) }, onBpmChange = { viewModel.updateBpm(it) },
onVolChange = { viewModel.updateVolume(it) }, onVolChange = { viewModel.updateVolume(it) },
onToggle = { viewModel.togglePlayback() } onToggle = { viewModel.togglePlayback() },
onTap = { viewModel.onTap() }
) )
} else { } else {
PortraitLayout( PortraitLayout(
@ -139,7 +142,8 @@ fun PacerScreen(viewModel: PacerViewModel, modifier: Modifier = Modifier) {
dotScale = dotScale.value, dotScale = dotScale.value,
onBpmChange = { viewModel.updateBpm(it) }, onBpmChange = { viewModel.updateBpm(it) },
onVolChange = { viewModel.updateVolume(it) }, onVolChange = { viewModel.updateVolume(it) },
onToggle = { viewModel.togglePlayback() } onToggle = { viewModel.togglePlayback() },
onTap = { viewModel.onTap() }
) )
} }
@ -181,6 +185,7 @@ private fun PortraitLayout(
onBpmChange: (Int) -> Unit, onBpmChange: (Int) -> Unit,
onVolChange: (Float) -> Unit, onVolChange: (Float) -> Unit,
onToggle: () -> Unit, onToggle: () -> Unit,
onTap: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -195,7 +200,7 @@ private fun PortraitLayout(
PulseRing(rippleScale, rippleAlpha, dotScale, size = 80.dp) PulseRing(rippleScale, rippleAlpha, dotScale, size = 80.dp)
BpmControls(bpm, onBpmChange) BpmControls(bpm, onBpmChange)
VolumeControl(volume, onVolChange) VolumeControl(volume, onVolChange)
StartStopButton(isPlaying, onToggle, Modifier.fillMaxWidth()) BottomButtons(isPlaying, onToggle, onTap)
} }
} }
@ -212,6 +217,7 @@ private fun LandscapeLayout(
onBpmChange: (Int) -> Unit, onBpmChange: (Int) -> Unit,
onVolChange: (Float) -> Unit, onVolChange: (Float) -> Unit,
onToggle: () -> Unit, onToggle: () -> Unit,
onTap: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -242,7 +248,7 @@ private fun LandscapeLayout(
) { ) {
BpmControls(bpm, onBpmChange) BpmControls(bpm, onBpmChange)
VolumeControl(volume, onVolChange) 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 @Composable
private fun StartStopButton(isPlaying: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) { private fun BottomButtons(isPlaying: Boolean, onToggle: () -> Unit, onTap: () -> Unit) {
OutlinedButton( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onClick = onToggle, OutlinedButton(
modifier = modifier, onClick = onTap,
shape = RectangleShape, modifier = Modifier.weight(1f),
border = BorderStroke(2.dp, if (isPlaying) ACCENT else FG), shape = RectangleShape,
colors = ButtonDefaults.outlinedButtonColors( border = BorderStroke(2.dp, DIM),
contentColor = if (isPlaying) BG else FG, colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (isPlaying) ACCENT else Color.Transparent contentColor = FG,
) containerColor = Color.Transparent
) { )
Text( ) {
text = if (isPlaying) "STOP" else "START", Text(
fontSize = 18.sp, text = "TAP",
fontWeight = FontWeight.Bold, fontSize = 18.sp,
fontFamily = JetBrainsMono, fontWeight = FontWeight.Bold,
letterSpacing = 3.sp, fontFamily = JetBrainsMono,
modifier = Modifier.padding(vertical = 6.dp) 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)
)
}
} }
} }