Skip to content

Commit 4540461

Browse files
author
Juan Sebastian Martinez
committed
Adding a LavaBeatsViewModel
This encapsulates the business logic of playin the haptics beat effect as well as handling the graphical visualization triggers and logic
1 parent a8bd569 commit 4540461

1 file changed

Lines changed: 241 additions & 0 deletions

File tree

  • samples/user-interface/haptics/src/main/java/com/example/platform/ui/haptics/lavabeats
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.platform.ui.haptics.lavabeats
18+
19+
import android.annotation.SuppressLint
20+
import android.app.Application
21+
import android.os.Build
22+
import android.os.Vibrator
23+
import android.os.vibrator.VibratorFrequencyProfile
24+
import androidx.annotation.RequiresApi
25+
import androidx.compose.runtime.State
26+
import androidx.compose.runtime.derivedStateOf
27+
import androidx.compose.runtime.mutableFloatStateOf
28+
import androidx.compose.runtime.mutableStateListOf
29+
import androidx.compose.runtime.mutableStateOf
30+
import androidx.core.content.ContextCompat
31+
import androidx.lifecycle.ViewModel
32+
import androidx.lifecycle.ViewModelProvider
33+
import androidx.lifecycle.viewModelScope
34+
import com.example.platform.ui.haptics.R
35+
import kotlin.time.Duration.Companion.milliseconds
36+
import kotlin.time.TimeSource
37+
import kotlinx.coroutines.Job
38+
import kotlinx.coroutines.delay
39+
import kotlinx.coroutines.launch
40+
41+
/**
42+
* The view model that encapsulates business logic to modify the parameters of the haptic beat
43+
* effect, play the effect, and control the corresponding visualization.
44+
*/
45+
class LavaBeatsViewModel(
46+
val messageToUser: String,
47+
val supportsHapticBeatEffect: Boolean,
48+
private val vibrator: Vibrator,
49+
) : ViewModel() {
50+
51+
// Haptic beat effect state variables
52+
val beatParameters = mutableStateListOf<BeatParameter>()
53+
54+
// This is safely used by checks on supportsBeatHapticEffect
55+
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
56+
private val vibratorFrequencyProfile: VibratorFrequencyProfile? = vibrator.frequencyProfile
57+
58+
// This is safely checked by supportsBeatHapticEffect
59+
@SuppressLint("NewApi")
60+
private val vibratorFrequencyRange: ClosedFloatingPointRange<Float>? =
61+
if (supportsHapticBeatEffect) {
62+
vibratorFrequencyProfile?.let { it.minFrequencyHz..it.maxFrequencyHz }
63+
} else {
64+
null
65+
}
66+
67+
// This is safely checked by supportsBeatHapticEffect
68+
@SuppressLint("NewApi")
69+
private val beatEffect = derivedStateOf {
70+
if (supportsHapticBeatEffect) {
71+
LavaBeatsHapticHelper.createEnvelopeBeatEffect(beatParameters = beatParameters)
72+
} else {
73+
null
74+
}
75+
}
76+
77+
val beatTimingParams = derivedStateOf {
78+
beatEffect.value?.timingParams ?: BeatEffectTimingParams.Empty
79+
}
80+
81+
// UI state control variables
82+
private val _isVibrating = mutableStateOf(false)
83+
val isVibrating: State<Boolean> = _isVibrating
84+
85+
private val _showVisualization = mutableStateOf(true)
86+
val showVisualization: State<Boolean> = _showVisualization
87+
88+
private val _showSettings = mutableStateOf(false)
89+
val showSettings: State<Boolean> = _showSettings
90+
91+
// Visualization pulsing effect variables
92+
private val timeSource = TimeSource.Monotonic
93+
private val _pulseTime = mutableFloatStateOf(0f)
94+
val pulseTime: State<Float> = _pulseTime
95+
private var pulseTimeJob: Job? = null
96+
97+
init {
98+
setDefaultParameters()
99+
updateFrequencyRanges()
100+
}
101+
102+
fun setDefaultParameters() {
103+
beatParameters.clear()
104+
beatParameters.addAll(DEFAULT_PARAMETERS)
105+
}
106+
107+
fun updateFrequencyRanges() {
108+
if (vibratorFrequencyRange != null) {
109+
beatParameters.forEachIndexed { i, parameter ->
110+
if (parameter.isFrequencyType) {
111+
val value = parameter.value.coerceIn(vibratorFrequencyRange)
112+
val newParameter = parameter.copy(value = value, range = vibratorFrequencyRange)
113+
beatParameters[i] = newParameter
114+
}
115+
}
116+
}
117+
}
118+
119+
fun onToggleShowSettings() {
120+
_showSettings.value = !_showSettings.value
121+
}
122+
123+
fun onToggleVisualization() {
124+
if (supportsHapticBeatEffect) {
125+
_showVisualization.value = !_showVisualization.value
126+
}
127+
}
128+
129+
fun onSettingChanged(key: Int, settingValue: Float) {
130+
val range = beatParameters[key].range
131+
beatParameters[key] = beatParameters[key].copy(value = settingValue.coerceIn(range))
132+
}
133+
134+
fun playHaptics() {
135+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
136+
137+
beatEffect.value?.let {
138+
viewModelScope.launch {
139+
beginPulseTime()
140+
vibrator.vibrate(it.vibrationEffect)
141+
_isVibrating.value = true
142+
delay(it.totalDurationEstimateMillis.toLong().milliseconds)
143+
_isVibrating.value = false
144+
endPulseTime()
145+
}
146+
}
147+
}
148+
149+
private fun beginPulseTime() {
150+
pulseTimeJob = viewModelScope.launch {
151+
val beginTime = timeSource.markNow()
152+
while (true) {
153+
val time = beginTime.elapsedNow().inWholeMilliseconds
154+
_pulseTime.floatValue = time / 1_000f
155+
delay(10L.milliseconds)
156+
}
157+
}
158+
}
159+
160+
private fun endPulseTime() {
161+
pulseTimeJob?.cancel()
162+
pulseTimeJob = null
163+
_pulseTime.floatValue = 0f
164+
}
165+
166+
companion object {
167+
168+
private val DEFAULT_PARAMETERS =
169+
listOf(
170+
BeatParameter(
171+
description = "First pulse starting frequency (Hz)",
172+
value = 70f,
173+
isFrequencyType = true,
174+
),
175+
BeatParameter(
176+
description = "First pulse end frequency (Hz)",
177+
value = 90f,
178+
isFrequencyType = true,
179+
),
180+
BeatParameter(
181+
description = "First pulse duration (ms)",
182+
value = 16f,
183+
range = 1f..500f,
184+
),
185+
BeatParameter(description = "First pulse amplitude", value = 0.4f, range = 0f..1f),
186+
BeatParameter(
187+
description = "Second pulse frequency (Hz)",
188+
value = 80f,
189+
isFrequencyType = true,
190+
),
191+
BeatParameter(description = "Second pulse amplitude", value = 0.5f, range = 0f..1f),
192+
BeatParameter(
193+
description = "First to second pulse delay (ms)",
194+
value = 280f,
195+
range = 1f..500f,
196+
),
197+
BeatParameter(
198+
description = "Beats per minute",
199+
value = 60f,
200+
range = 1f..120f,
201+
steps = 1,
202+
),
203+
BeatParameter(
204+
description = "Number of beats",
205+
value = 5f,
206+
range = 1f..7f,
207+
steps = 5,
208+
),
209+
)
210+
211+
fun provideFactory(application: Application): ViewModelProvider.Factory =
212+
object : ViewModelProvider.Factory {
213+
@Suppress("UNCHECKED_CAST")
214+
override fun <T : ViewModel> create(modelClass: Class<T>): T {
215+
val vibrator =
216+
ContextCompat.getSystemService(
217+
/*context = */ application,
218+
/*serviceClass = */ Vibrator::class.java,
219+
)!!
220+
221+
var messageToUser: String
222+
var supportsHapticBeatEffect: Boolean
223+
if (
224+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA &&
225+
vibrator.areEnvelopeEffectsSupported()
226+
) {
227+
supportsHapticBeatEffect = true
228+
messageToUser = ""
229+
} else {
230+
supportsHapticBeatEffect = false
231+
messageToUser = application.getString(R.string.message_not_supported)
232+
}
233+
234+
val viewModel =
235+
LavaBeatsViewModel(messageToUser, supportsHapticBeatEffect, vibrator)
236+
237+
return viewModel as T
238+
}
239+
}
240+
}
241+
}

0 commit comments

Comments
 (0)