react-native-waveform-player 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AudioWaveform.podspec +29 -0
- package/LICENSE +20 -0
- package/README.md +296 -0
- package/android/build.gradle +67 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/audiowaveform/AudioPlayerEngine.kt +353 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformEvent.kt +22 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformPackage.kt +17 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformView.kt +715 -0
- package/android/src/main/java/com/audiowaveform/AudioWaveformViewManager.kt +234 -0
- package/android/src/main/java/com/audiowaveform/PlayPauseButton.kt +106 -0
- package/android/src/main/java/com/audiowaveform/SpeedPillView.kt +70 -0
- package/android/src/main/java/com/audiowaveform/WaveformBarsView.kt +358 -0
- package/android/src/main/java/com/audiowaveform/WaveformDecoder.kt +240 -0
- package/android/src/main/res/drawable/pause_fill.xml +15 -0
- package/android/src/main/res/drawable/play_fill.xml +15 -0
- package/ios/AudioPlayerEngine.swift +281 -0
- package/ios/AudioWaveformView.h +14 -0
- package/ios/AudioWaveformView.mm +307 -0
- package/ios/AudioWaveformViewImpl.swift +835 -0
- package/ios/PlayPauseButton.swift +118 -0
- package/ios/SpeedPillView.swift +70 -0
- package/ios/WaveformBarsView.swift +327 -0
- package/ios/WaveformDecoder.swift +332 -0
- package/lib/module/AudioWaveformView.js +8 -0
- package/lib/module/AudioWaveformView.js.map +1 -0
- package/lib/module/AudioWaveformView.native.js +79 -0
- package/lib/module/AudioWaveformView.native.js.map +1 -0
- package/lib/module/AudioWaveformViewNativeComponent.ts +95 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/AudioWaveformView.d.ts +233 -0
- package/lib/typescript/src/AudioWaveformView.d.ts.map +1 -0
- package/lib/typescript/src/AudioWaveformView.native.d.ts +335 -0
- package/lib/typescript/src/AudioWaveformView.native.d.ts.map +1 -0
- package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts +71 -0
- package/lib/typescript/src/AudioWaveformViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +138 -7
- package/src/AudioWaveformView.native.tsx +281 -0
- package/src/AudioWaveformView.tsx +96 -0
- package/src/AudioWaveformViewNativeComponent.ts +95 -0
- package/src/index.tsx +13 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
5
|
+
import com.facebook.react.bridge.ReadableArray
|
|
6
|
+
import com.facebook.react.bridge.ReadableMap
|
|
7
|
+
import com.facebook.react.bridge.WritableMap
|
|
8
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
9
|
+
import com.facebook.react.uimanager.SimpleViewManager
|
|
10
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
11
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
12
|
+
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
13
|
+
import com.facebook.react.uimanager.events.EventDispatcher
|
|
14
|
+
import com.facebook.react.viewmanagers.AudioWaveformViewManagerDelegate
|
|
15
|
+
import com.facebook.react.viewmanagers.AudioWaveformViewManagerInterface
|
|
16
|
+
|
|
17
|
+
@ReactModule(name = AudioWaveformViewManager.NAME)
|
|
18
|
+
class AudioWaveformViewManager(@Suppress("UNUSED_PARAMETER") context: ReactApplicationContext) :
|
|
19
|
+
SimpleViewManager<AudioWaveformView>(),
|
|
20
|
+
AudioWaveformViewManagerInterface<AudioWaveformView> {
|
|
21
|
+
|
|
22
|
+
private val mDelegate: ViewManagerDelegate<AudioWaveformView> =
|
|
23
|
+
AudioWaveformViewManagerDelegate(this)
|
|
24
|
+
|
|
25
|
+
override fun getDelegate(): ViewManagerDelegate<AudioWaveformView> = mDelegate
|
|
26
|
+
|
|
27
|
+
override fun getName(): String = NAME
|
|
28
|
+
|
|
29
|
+
public override fun createViewInstance(context: ThemedReactContext): AudioWaveformView {
|
|
30
|
+
val view = AudioWaveformView(context)
|
|
31
|
+
wireEvents(view)
|
|
32
|
+
return view
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Hook the AudioWaveformView's callback closures up to the Fabric event dispatcher. */
|
|
36
|
+
private fun wireEvents(view: AudioWaveformView) {
|
|
37
|
+
view.onLoad = { durationMs ->
|
|
38
|
+
dispatchEvent(view, "topLoad") { putInt("durationMs", durationMs) }
|
|
39
|
+
}
|
|
40
|
+
view.onLoadError = { message ->
|
|
41
|
+
dispatchEvent(view, "topLoadError") { putString("message", message) }
|
|
42
|
+
}
|
|
43
|
+
view.onPlayerStateChange = { state, isPlaying, speed, error ->
|
|
44
|
+
dispatchEvent(view, "topPlayerStateChange") {
|
|
45
|
+
putString("state", state)
|
|
46
|
+
putBoolean("isPlaying", isPlaying)
|
|
47
|
+
putDouble("speed", speed.toDouble())
|
|
48
|
+
putString("error", error)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
view.onTimeUpdate = { currentMs, durationMs ->
|
|
52
|
+
dispatchEvent(view, "topTimeUpdate") {
|
|
53
|
+
putInt("currentTimeMs", currentMs)
|
|
54
|
+
putInt("durationMs", durationMs)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
view.onSeek = { positionMs ->
|
|
58
|
+
dispatchEvent(view, "topSeek") { putInt("positionMs", positionMs) }
|
|
59
|
+
}
|
|
60
|
+
view.onEnd = {
|
|
61
|
+
dispatchEvent(view, "topEnd") {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private inline fun dispatchEvent(
|
|
66
|
+
view: AudioWaveformView,
|
|
67
|
+
eventName: String,
|
|
68
|
+
builder: WritableMap.() -> Unit
|
|
69
|
+
) {
|
|
70
|
+
val context = view.context as? ThemedReactContext ?: return
|
|
71
|
+
val dispatcher: EventDispatcher? = UIManagerHelper.getEventDispatcherForReactTag(
|
|
72
|
+
context,
|
|
73
|
+
view.id
|
|
74
|
+
)
|
|
75
|
+
val surfaceId = UIManagerHelper.getSurfaceId(context)
|
|
76
|
+
val payload = Arguments.createMap()
|
|
77
|
+
payload.builder()
|
|
78
|
+
dispatcher?.dispatchEvent(
|
|
79
|
+
AudioWaveformEvent(surfaceId, view.id, eventName, payload)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// region Fabric prop setters (codegen interface) ----------------------------
|
|
84
|
+
|
|
85
|
+
override fun setSource(view: AudioWaveformView, value: ReadableMap?) {
|
|
86
|
+
val uri = value?.getString("uri") ?: ""
|
|
87
|
+
view.sourceUri = uri
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override fun setSamples(view: AudioWaveformView, value: ReadableArray?) {
|
|
91
|
+
view.setSamplesFromArray(value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override fun setPlayedBarColor(view: AudioWaveformView, value: Int?) {
|
|
95
|
+
view.playedBarColor = value ?: android.graphics.Color.WHITE
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override fun setUnplayedBarColor(view: AudioWaveformView, value: Int?) {
|
|
99
|
+
view.unplayedBarColor =
|
|
100
|
+
value ?: android.graphics.Color.argb(128, 255, 255, 255)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
override fun setBarWidth(view: AudioWaveformView, value: Float) {
|
|
104
|
+
view.barWidthDp = if (value > 0) value else 3f
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
override fun setBarGap(view: AudioWaveformView, value: Float) {
|
|
108
|
+
view.barGapDp = if (value >= 0) value else 2f
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override fun setBarRadius(view: AudioWaveformView, value: Float) {
|
|
112
|
+
view.barRadiusDp = value // -1 (or any negative) means "auto" = barWidth / 2
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override fun setBarCount(view: AudioWaveformView, value: Int) {
|
|
116
|
+
view.barCountOverride = value.coerceAtLeast(0)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override fun setContainerBackgroundColor(view: AudioWaveformView, value: Int?) {
|
|
120
|
+
view.containerBackgroundColor =
|
|
121
|
+
value ?: android.graphics.Color.parseColor("#3478F6")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
override fun setContainerBorderRadius(view: AudioWaveformView, value: Float) {
|
|
125
|
+
view.containerBorderRadiusDp = if (value >= 0) value else 16f
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
override fun setShowBackground(view: AudioWaveformView, value: Boolean) {
|
|
129
|
+
view.showBackground = value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override fun setShowPlayButton(view: AudioWaveformView, value: Boolean) {
|
|
133
|
+
view.showPlayButton = value
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
override fun setPlayButtonColor(view: AudioWaveformView, value: Int?) {
|
|
137
|
+
view.playButtonColor = value ?: android.graphics.Color.WHITE
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override fun setShowTime(view: AudioWaveformView, value: Boolean) {
|
|
141
|
+
view.showTime = value
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
override fun setTimeColor(view: AudioWaveformView, value: Int?) {
|
|
145
|
+
view.timeColor = value ?: android.graphics.Color.WHITE
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override fun setTimeMode(view: AudioWaveformView, value: String?) {
|
|
149
|
+
view.timeMode = value ?: "count-up"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
override fun setShowSpeedControl(view: AudioWaveformView, value: Boolean) {
|
|
153
|
+
view.showSpeedControl = value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
override fun setSpeedColor(view: AudioWaveformView, value: Int?) {
|
|
157
|
+
view.speedColor = value ?: android.graphics.Color.WHITE
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
override fun setSpeedBackgroundColor(view: AudioWaveformView, value: Int?) {
|
|
161
|
+
view.speedBackgroundColor =
|
|
162
|
+
value ?: android.graphics.Color.argb(64, 255, 255, 255)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
override fun setSpeeds(view: AudioWaveformView, value: ReadableArray?) {
|
|
166
|
+
view.setSpeedsFromArray(value)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
override fun setDefaultSpeed(view: AudioWaveformView, value: Float) {
|
|
170
|
+
view.defaultSpeed = if (value > 0) value else 1f
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
override fun setAutoPlay(view: AudioWaveformView, value: Boolean) {
|
|
174
|
+
view.autoPlay = value
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
override fun setInitialPositionMs(view: AudioWaveformView, value: Int) {
|
|
178
|
+
view.initialPositionMs = value.coerceAtLeast(0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
override fun setLoop(view: AudioWaveformView, value: Boolean) {
|
|
182
|
+
view.loopPlayback = value
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
override fun setPlayInBackground(view: AudioWaveformView, value: Boolean) {
|
|
186
|
+
view.playInBackground = value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
override fun setPauseUiUpdatesInBackground(view: AudioWaveformView, value: Boolean) {
|
|
190
|
+
view.pauseUiUpdatesInBackground = value
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
override fun setControlledPlaying(view: AudioWaveformView, value: Int) {
|
|
194
|
+
view.controlledPlaying = value
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
override fun setControlledSpeed(view: AudioWaveformView, value: Float) {
|
|
198
|
+
view.controlledSpeed = value
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// endregion
|
|
202
|
+
|
|
203
|
+
// region Commands -----------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
override fun play(view: AudioWaveformView) {
|
|
206
|
+
view.play()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
override fun pause(view: AudioWaveformView) {
|
|
210
|
+
view.pause()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
override fun toggle(view: AudioWaveformView) {
|
|
214
|
+
view.toggle()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
override fun seekTo(view: AudioWaveformView, positionMs: Int) {
|
|
218
|
+
view.seekTo(positionMs)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
override fun setSpeed(view: AudioWaveformView, speed: Float) {
|
|
222
|
+
view.setSpeedValue(speed)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// endregion
|
|
226
|
+
|
|
227
|
+
override fun onDropViewInstance(view: AudioWaveformView) {
|
|
228
|
+
super.onDropViewInstance(view)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
companion object {
|
|
232
|
+
const val NAME = "AudioWaveformView"
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.res.ColorStateList
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.util.AttributeSet
|
|
7
|
+
import android.view.Gravity
|
|
8
|
+
import android.view.View
|
|
9
|
+
import android.widget.FrameLayout
|
|
10
|
+
import android.widget.ImageView
|
|
11
|
+
import android.widget.ProgressBar
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Play/pause button backed by the bundled vector drawables
|
|
15
|
+
* `R.drawable.play_fill` and `R.drawable.pause_fill`. Tintable via `iconColor`.
|
|
16
|
+
*
|
|
17
|
+
* While `isLoading` is `true`, the icon is hidden and a native
|
|
18
|
+
* indeterminate `ProgressBar` is shown in its place. The view stays
|
|
19
|
+
* clickable so callers can queue a "play once ready" intent.
|
|
20
|
+
*/
|
|
21
|
+
class PlayPauseButton @JvmOverloads constructor(
|
|
22
|
+
context: Context,
|
|
23
|
+
attrs: AttributeSet? = null,
|
|
24
|
+
defStyleAttr: Int = 0
|
|
25
|
+
) : FrameLayout(context, attrs, defStyleAttr) {
|
|
26
|
+
|
|
27
|
+
var isPlaying: Boolean = false
|
|
28
|
+
set(value) {
|
|
29
|
+
if (field == value) return
|
|
30
|
+
field = value
|
|
31
|
+
updateImage()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var isLoading: Boolean = false
|
|
35
|
+
set(value) {
|
|
36
|
+
if (field == value) return
|
|
37
|
+
field = value
|
|
38
|
+
updateLoadingState()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var iconColor: Int = Color.WHITE
|
|
42
|
+
set(value) {
|
|
43
|
+
field = value
|
|
44
|
+
imageView.setColorFilter(value)
|
|
45
|
+
applySpinnerTint(value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private val imageView = ImageView(context).apply {
|
|
49
|
+
scaleType = ImageView.ScaleType.FIT_CENTER
|
|
50
|
+
setColorFilter(Color.WHITE)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private val spinner = ProgressBar(context).apply {
|
|
54
|
+
isIndeterminate = true
|
|
55
|
+
visibility = View.GONE
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
init {
|
|
59
|
+
addView(
|
|
60
|
+
imageView,
|
|
61
|
+
LayoutParams(
|
|
62
|
+
LayoutParams.MATCH_PARENT,
|
|
63
|
+
LayoutParams.MATCH_PARENT
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
// Spinner sits on top of the icon (which is hidden while loading).
|
|
67
|
+
// Wrap content + center so the OS-default size is preserved.
|
|
68
|
+
addView(
|
|
69
|
+
spinner,
|
|
70
|
+
LayoutParams(
|
|
71
|
+
LayoutParams.WRAP_CONTENT,
|
|
72
|
+
LayoutParams.WRAP_CONTENT,
|
|
73
|
+
Gravity.CENTER
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
isClickable = true
|
|
77
|
+
isFocusable = true
|
|
78
|
+
applySpinnerTint(iconColor)
|
|
79
|
+
updateImage()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private fun updateImage() {
|
|
83
|
+
val res = if (isPlaying) R.drawable.pause_fill else R.drawable.play_fill
|
|
84
|
+
imageView.setImageResource(res)
|
|
85
|
+
imageView.setColorFilter(iconColor)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun updateLoadingState() {
|
|
89
|
+
if (isLoading) {
|
|
90
|
+
imageView.visibility = View.INVISIBLE
|
|
91
|
+
spinner.visibility = View.VISIBLE
|
|
92
|
+
} else {
|
|
93
|
+
imageView.visibility = View.VISIBLE
|
|
94
|
+
spinner.visibility = View.GONE
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private fun applySpinnerTint(color: Int) {
|
|
99
|
+
spinner.indeterminateTintList = ColorStateList.valueOf(color)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override fun performClick(): Boolean {
|
|
103
|
+
super.performClick()
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package com.audiowaveform
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Color
|
|
5
|
+
import android.graphics.Typeface
|
|
6
|
+
import android.graphics.drawable.GradientDrawable
|
|
7
|
+
import android.util.AttributeSet
|
|
8
|
+
import android.view.Gravity
|
|
9
|
+
import android.widget.TextView
|
|
10
|
+
import kotlin.math.floor
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rounded "1.5x" speed-rate label. Tap-to-cycle is wired up via `onTap`;
|
|
14
|
+
* the parent (`AudioWaveformView`) is responsible for applying the new rate.
|
|
15
|
+
*/
|
|
16
|
+
class SpeedPillView @JvmOverloads constructor(
|
|
17
|
+
context: Context,
|
|
18
|
+
attrs: AttributeSet? = null,
|
|
19
|
+
defStyleAttr: Int = 0
|
|
20
|
+
) : TextView(context, attrs, defStyleAttr) {
|
|
21
|
+
|
|
22
|
+
private val pillBackground = GradientDrawable().apply {
|
|
23
|
+
shape = GradientDrawable.RECTANGLE
|
|
24
|
+
setColor(Color.argb(64, 255, 255, 255))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var pillColor: Int = Color.argb(64, 255, 255, 255)
|
|
28
|
+
set(value) {
|
|
29
|
+
field = value
|
|
30
|
+
pillBackground.setColor(value)
|
|
31
|
+
invalidate()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var onTap: (() -> Unit)? = null
|
|
35
|
+
|
|
36
|
+
init {
|
|
37
|
+
gravity = Gravity.CENTER
|
|
38
|
+
setTextColor(Color.WHITE)
|
|
39
|
+
textSize = 12f
|
|
40
|
+
setTypeface(typeface, Typeface.BOLD)
|
|
41
|
+
background = pillBackground
|
|
42
|
+
// Vertical padding stays small; the pill height is driven by intrinsic size.
|
|
43
|
+
val hPadding = (8f * resources.displayMetrics.density).toInt()
|
|
44
|
+
val vPadding = (2f * resources.displayMetrics.density).toInt()
|
|
45
|
+
setPadding(hPadding, vPadding, hPadding, vPadding)
|
|
46
|
+
isClickable = true
|
|
47
|
+
isFocusable = true
|
|
48
|
+
setOnClickListener {
|
|
49
|
+
animate().alpha(0.6f).setDuration(80).withEndAction {
|
|
50
|
+
animate().alpha(1.0f).setDuration(120).start()
|
|
51
|
+
}.start()
|
|
52
|
+
onTap?.invoke()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
57
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
58
|
+
// Make the corner radius track the height for a perfect pill shape.
|
|
59
|
+
pillBackground.cornerRadius = h / 2f
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fun setSpeed(speed: Float) {
|
|
63
|
+
val rounded = (speed * 10f).toInt().toFloat() / 10f
|
|
64
|
+
text = if (rounded == floor(rounded)) {
|
|
65
|
+
"${rounded.toInt()}x"
|
|
66
|
+
} else {
|
|
67
|
+
String.format("%.1fx", rounded)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|