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
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue