@iternio/react-native-auto-play 0.5.5 → 0.5.7
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/README.md +2 -1
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAndroidWindowInformation.kt +49 -0
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlay.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VirtualRenderer.kt +116 -29
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputManager.kt +2 -1
- package/ios/utils/VoiceInputManager.swift +41 -47
- package/lib/HybridAutoPlay.d.ts +2 -0
- package/lib/HybridAutoPlay.js +2 -0
- package/lib/components/OnAppearedChildRenderer.d.ts +10 -0
- package/lib/components/OnAppearedChildRenderer.js +26 -0
- package/lib/components/WindowInformationWrapper.d.ts +15 -0
- package/lib/components/WindowInformationWrapper.js +17 -0
- package/lib/hooks/useIsAutoPlayFocused.d.ts +7 -0
- package/lib/hooks/useIsAutoPlayFocused.js +20 -0
- package/lib/hybrid/HybridAndroidWindowInformation.android.d.ts +2 -0
- package/lib/hybrid/HybridAndroidWindowInformation.android.js +2 -0
- package/lib/hybrid/HybridAndroidWindowInformation.d.ts +2 -0
- package/lib/hybrid/HybridAndroidWindowInformation.js +1 -0
- package/lib/hybrid.d.ts +2 -0
- package/lib/hybrid.js +2 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/lib/scenes/AutoPlayCluster.js +6 -1
- package/lib/specs/AndroidWindowInformation.nitro.d.ts +22 -0
- package/lib/specs/AndroidWindowInformation.nitro.js +1 -0
- package/lib/specs/AutomotivePermissionRequestTemplate.d.ts +11 -0
- package/lib/specs/AutomotivePermissionRequestTemplate.js +1 -0
- package/lib/specs/AutomotivePermissionRequestTemplate.nitro.d.ts +11 -0
- package/lib/specs/AutomotivePermissionRequestTemplate.nitro.js +1 -0
- package/lib/templates/AutomotivePermissionRequestTemplate.d.ts +23 -0
- package/lib/templates/AutomotivePermissionRequestTemplate.js +18 -0
- package/lib/templates/MapTemplate.js +6 -1
- package/lib/types/Glyphmap.d.ts +4105 -0
- package/lib/types/Glyphmap.js +4105 -0
- package/lib/types/RootComponent.d.ts +4 -0
- package/lib/utils/NitroOptionsPanel.d.ts +67 -0
- package/lib/utils/NitroOptionsPanel.js +48 -0
- package/nitro.json +6 -0
- package/nitrogen/generated/android/ReactNativeAutoPlay+autolinking.cmake +2 -0
- package/nitrogen/generated/android/ReactNativeAutoPlayOnLoad.cpp +18 -0
- package/nitrogen/generated/android/c++/JFunc_void_WindowInformation.hpp +77 -0
- package/nitrogen/generated/android/c++/JHybridAndroidWindowInformationSpec.cpp +68 -0
- package/nitrogen/generated/android/c++/JHybridAndroidWindowInformationSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JWindowInformation.hpp +65 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/Func_void_WindowInformation.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAndroidWindowInformationSpec.kt +59 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/WindowInformation.kt +61 -0
- package/nitrogen/generated/shared/c++/HybridAndroidWindowInformationSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridAndroidWindowInformationSpec.hpp +65 -0
- package/nitrogen/generated/shared/c++/WindowInformation.hpp +91 -0
- package/package.json +1 -1
- package/src/components/WindowInformationWrapper.tsx +30 -0
- package/src/hybrid/HybridAndroidWindowInformation.android.ts +5 -0
- package/src/hybrid/HybridAndroidWindowInformation.ts +3 -0
- package/src/index.ts +2 -1
- package/src/scenes/AutoPlayCluster.ts +6 -1
- package/src/specs/AndroidWindowInformation.nitro.ts +24 -0
- package/src/templates/MapTemplate.ts +6 -1
- package/src/types/RootComponent.ts +4 -0
package/README.md
CHANGED
|
@@ -1074,7 +1074,8 @@ CarPlayDashboard.setButtons([
|
|
|
1074
1074
|
|
|
1075
1075
|
- **Broken exceptions with `react-native-skia`**: When using `react-native-skia` exceptions on iOS are not reported correctly. This is fixed since version `2.4.19` of `react-native-skia`. For more details, see this [pull request](https://github.com/Shopify/react-native-skia/pull/3595) and [issue](https://github.com/Shopify/react-native-skia/issues/3635).
|
|
1076
1076
|
- **AppState on iOS**: The `AppState` module from React Native does not work correctly on iOS because this library uses scenes, which are not supported by the stock `AppState` module. This library provides a custom state listener that works for both Android and iOS. Use `HybridAutoPlay.addListenerRenderState` instead of `AppState`.
|
|
1077
|
-
- **Timers stop on screen lock**: iOS stops all timers when the device
|
|
1077
|
+
- **Timers stop on screen lock**: iOS stops all timers when the device main screen is turned off. To ensure timers continue to run (which is often necessary for background tasks related to autoplay), a patch for `react-native` is required. A patch is included in the root `patches/` directory and can be applied using `patch-package`.
|
|
1078
|
+
In case you are using Expo SDK >= 56 make sure to set `buildReactNativeFromSource` to `true` in your app config for [expo-build-properties](https://docs.expo.dev/versions/latest/sdk/build-properties/#sharedbuildconfigfields), otherwise the patch can't be applied.
|
|
1078
1079
|
- **expo-splash-screen stuck on iOS**: The `expo-splash-screen` module is broken on iOS because it does not support scenes, which are used by this library. This can cause the splash screen to be stuck on either the mobile device or on CarPlay. To fix this, a patch for `expo-splash-screen` is included in the root `patches/` directory and can be applied using `patch-package`. After applying the patch, you can hide the splash screen for a specific scene by passing the module name to the `hide` or `hideAsync` function. The module name can be one of the values from the `AutoPlayModules` enum or the UUID of a cluster screen.
|
|
1079
1080
|
```tsx
|
|
1080
1081
|
import { hideAsync } from 'expo-splash-screen';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
package com.margelo.nitro.swe.iternio.reactnativeautoplay
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
4
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
5
|
+
|
|
6
|
+
class HybridAndroidWindowInformation : HybridAndroidWindowInformationSpec() {
|
|
7
|
+
init {
|
|
8
|
+
windowInformationListeners.clear()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
override fun addWindowInformationListener(
|
|
12
|
+
moduleName: String, callback: (WindowInformation) -> Unit
|
|
13
|
+
): () -> Unit {
|
|
14
|
+
val callbacks = windowInformationListeners.getOrPut(moduleName) {
|
|
15
|
+
CopyOnWriteArrayList()
|
|
16
|
+
}
|
|
17
|
+
callbacks.add(callback)
|
|
18
|
+
|
|
19
|
+
VirtualRenderer.getWindowInformation(moduleName)?.let {
|
|
20
|
+
callback(it)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
windowInformationListeners[moduleName]?.let {
|
|
25
|
+
it.remove(callback)
|
|
26
|
+
if (it.isEmpty()) {
|
|
27
|
+
windowInformationListeners.remove(moduleName)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
companion object {
|
|
34
|
+
const val TAG = "HybridAndroidWindowInformation"
|
|
35
|
+
|
|
36
|
+
private val windowInformationListeners =
|
|
37
|
+
ConcurrentHashMap<String, CopyOnWriteArrayList<(WindowInformation) -> Unit>>()
|
|
38
|
+
|
|
39
|
+
fun removeListeners(templateId: String) {
|
|
40
|
+
windowInformationListeners.remove(templateId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun emitWindowInformation(moduleName: String, window: WindowInformation) {
|
|
44
|
+
windowInformationListeners[moduleName]?.forEach {
|
|
45
|
+
it(window)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlay.kt
CHANGED
|
@@ -264,6 +264,7 @@ class HybridAutoPlay : HybridAutoPlaySpec() {
|
|
|
264
264
|
fun removeListeners(templateId: String) {
|
|
265
265
|
renderStateListeners.remove(templateId)
|
|
266
266
|
safeAreaInsetsListeners.remove(templateId)
|
|
267
|
+
HybridAndroidWindowInformation.removeListeners(templateId)
|
|
267
268
|
}
|
|
268
269
|
|
|
269
270
|
fun emit(event: EventName) {
|
package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VirtualRenderer.kt
CHANGED
|
@@ -42,20 +42,24 @@ class VirtualRenderer(
|
|
|
42
42
|
) {
|
|
43
43
|
private var virtualDisplay: VirtualDisplay? = null
|
|
44
44
|
|
|
45
|
-
private
|
|
45
|
+
private var reactSurfaceImpl: ReactSurfaceImpl? = null
|
|
46
46
|
private var reactSurfaceView: ReactSurfaceView? = null
|
|
47
47
|
private var reactSurfaceId: Int? = null
|
|
48
48
|
|
|
49
49
|
private var height: Int = 0
|
|
50
50
|
private var width: Int = 0
|
|
51
|
+
private var dpi: Int = 0
|
|
51
52
|
|
|
52
53
|
private var splashWillDisappear = false
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* scale is the actual scale factor required to calculate proper insets and is passed in initialProperties to js side
|
|
56
|
+
* scale is the actual scale factor required to calculate proper insets and is passed in initialProperties to js side.
|
|
57
|
+
* The density is derived from the surface container's dpi so it always matches the VirtualDisplay,
|
|
58
|
+
* and is recomputed on every onSurfaceAvailable since the host may recreate the surface with new metrics.
|
|
56
59
|
*/
|
|
57
|
-
private
|
|
58
|
-
|
|
60
|
+
private var virtualScreenDensity = context.resources.displayMetrics.density
|
|
61
|
+
var scale = BuildConfig.SCALE_FACTOR * virtualScreenDensity
|
|
62
|
+
private set
|
|
59
63
|
|
|
60
64
|
private fun isSurfaceReady(surfaceContainer: SurfaceContainer): Boolean {
|
|
61
65
|
return surfaceContainer.surface != null && surfaceContainer.dpi != 0 && surfaceContainer.height != 0 && surfaceContainer.width != 0
|
|
@@ -68,7 +72,7 @@ class VirtualRenderer(
|
|
|
68
72
|
val areaDebouncer = Debouncer(200)
|
|
69
73
|
|
|
70
74
|
// 12dp seems to be the default margin on AA for the ETA widget and the maneuver so use it as fallback
|
|
71
|
-
val defaultMargin = (12.0 *
|
|
75
|
+
val defaultMargin get() = (12.0 * virtualScreenDensity).toInt()
|
|
72
76
|
var minMargin = Int.MAX_VALUE
|
|
73
77
|
var stableArea = Rect(0, 0, 0, 0)
|
|
74
78
|
var visibleArea = Rect(0, 0, 0, 0)
|
|
@@ -78,25 +82,16 @@ class VirtualRenderer(
|
|
|
78
82
|
return
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
virtualDisplay?.release()
|
|
83
|
-
virtualDisplay = manager.createVirtualDisplay(
|
|
84
|
-
moduleName,
|
|
85
|
-
surfaceContainer.width,
|
|
86
|
-
surfaceContainer.height,
|
|
87
|
-
surfaceContainer.dpi,
|
|
88
|
-
surfaceContainer.surface,
|
|
89
|
-
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION,
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
val display = virtualDisplay?.display ?: return
|
|
93
|
-
height = surfaceContainer.height
|
|
94
|
-
width = surfaceContainer.width
|
|
95
|
-
|
|
96
|
-
initRenderer(display)
|
|
85
|
+
applySurface(surfaceContainer)
|
|
97
86
|
}
|
|
98
87
|
|
|
99
88
|
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
|
|
89
|
+
// the host re-emits visible/stable areas for the next surface, so drop state derived
|
|
90
|
+
// from the old surface instead of combining it with the new dimensions
|
|
91
|
+
minMargin = Int.MAX_VALUE
|
|
92
|
+
stableArea = Rect(0, 0, 0, 0)
|
|
93
|
+
visibleArea = Rect(0, 0, 0, 0)
|
|
94
|
+
|
|
100
95
|
virtualDisplay?.release()
|
|
101
96
|
virtualDisplay = null
|
|
102
97
|
}
|
|
@@ -230,6 +225,46 @@ class VirtualRenderer(
|
|
|
230
225
|
})
|
|
231
226
|
}
|
|
232
227
|
|
|
228
|
+
@MainThread
|
|
229
|
+
private fun applySurface(surfaceContainer: SurfaceContainer) {
|
|
230
|
+
val surfaceWidth = surfaceContainer.width
|
|
231
|
+
val surfaceHeight = surfaceContainer.height
|
|
232
|
+
|
|
233
|
+
// The host signals surface size changes only via onSurfaceDestroyed -> onSurfaceAvailable
|
|
234
|
+
// with new dimensions, so all size-derived state (layout params, scale, Fabric measure
|
|
235
|
+
// specs) must be recomputed when the metrics differ from the current ones. The react
|
|
236
|
+
// surface itself is kept alive so JS state survives the resize; the JS side is informed
|
|
237
|
+
// via the windowInformation listener since the window initial prop stays stale.
|
|
238
|
+
val isResize = reactSurfaceView != null && (width != surfaceWidth
|
|
239
|
+
|| height != surfaceHeight || dpi != surfaceContainer.dpi)
|
|
240
|
+
|
|
241
|
+
val manager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
|
242
|
+
virtualDisplay?.release()
|
|
243
|
+
virtualDisplay = manager.createVirtualDisplay(
|
|
244
|
+
moduleName,
|
|
245
|
+
surfaceWidth,
|
|
246
|
+
surfaceHeight,
|
|
247
|
+
surfaceContainer.dpi,
|
|
248
|
+
surfaceContainer.surface,
|
|
249
|
+
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
val display = virtualDisplay?.display ?: return
|
|
253
|
+
height = surfaceHeight
|
|
254
|
+
width = surfaceWidth
|
|
255
|
+
dpi = surfaceContainer.dpi
|
|
256
|
+
virtualScreenDensity = surfaceContainer.dpi / 160f
|
|
257
|
+
scale = BuildConfig.SCALE_FACTOR * virtualScreenDensity
|
|
258
|
+
|
|
259
|
+
initRenderer(display)
|
|
260
|
+
|
|
261
|
+
if (isResize) {
|
|
262
|
+
getWindowInformation(moduleName)?.let {
|
|
263
|
+
HybridAndroidWindowInformation.emitWindowInformation(moduleName, it)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
233
268
|
private fun getMapTemplateConfig(): MapTemplateConfig? {
|
|
234
269
|
val screenManager = AndroidAutoScreen.getScreen(moduleName)?.screenManager ?: return null
|
|
235
270
|
val marker = screenManager.top.marker ?: return null
|
|
@@ -290,19 +325,48 @@ class VirtualRenderer(
|
|
|
290
325
|
val appTheme = context.applicationContext.applicationInfo.theme
|
|
291
326
|
val themedContext = ContextThemeWrapper(context.applicationContext, appTheme)
|
|
292
327
|
|
|
293
|
-
|
|
294
|
-
|
|
328
|
+
val surfaceImpl = reactSurfaceImpl ?: ReactSurfaceImpl(
|
|
329
|
+
themedContext, moduleName, initialProperties
|
|
330
|
+
).also {
|
|
331
|
+
reactSurfaceImpl = it
|
|
295
332
|
}
|
|
296
333
|
|
|
297
334
|
var splashScreenView: View? = null
|
|
298
335
|
|
|
299
336
|
reactSurfaceView?.let {
|
|
300
337
|
(it.parent as ViewGroup).removeView(it)
|
|
338
|
+
|
|
339
|
+
// the surface may have been recreated with new dimensions, so reapply all
|
|
340
|
+
// size-derived values and push the new layout specs into the running Fabric surface
|
|
341
|
+
it.layoutParams = FrameLayout.LayoutParams(
|
|
342
|
+
(width / reactNativeScale).toInt(), (height / reactNativeScale).toInt()
|
|
343
|
+
)
|
|
344
|
+
it.scaleX = reactNativeScale
|
|
345
|
+
it.scaleY = reactNativeScale
|
|
346
|
+
|
|
347
|
+
reactSurfaceId?.let { surfaceId ->
|
|
348
|
+
fabricUiManager.updateRootLayoutSpecs(
|
|
349
|
+
surfaceId,
|
|
350
|
+
View.MeasureSpec.makeMeasureSpec(
|
|
351
|
+
(width / reactNativeScale).toInt(), View.MeasureSpec.EXACTLY
|
|
352
|
+
),
|
|
353
|
+
View.MeasureSpec.makeMeasureSpec(
|
|
354
|
+
(height / reactNativeScale).toInt(), View.MeasureSpec.EXACTLY
|
|
355
|
+
),
|
|
356
|
+
0,
|
|
357
|
+
0
|
|
358
|
+
)
|
|
359
|
+
}
|
|
301
360
|
} ?: run {
|
|
361
|
+
// skip the splash when the surface is rebuilt after a resize, content is already loaded
|
|
302
362
|
splashScreenView =
|
|
303
|
-
if (isCluster
|
|
363
|
+
if (isCluster && !splashWillDisappear) {
|
|
364
|
+
getClusterSplashScreen(themedContext, height, width)
|
|
365
|
+
} else {
|
|
366
|
+
null
|
|
367
|
+
}
|
|
304
368
|
|
|
305
|
-
val surfaceView = ReactSurfaceView(themedContext,
|
|
369
|
+
val surfaceView = ReactSurfaceView(themedContext, surfaceImpl).apply {
|
|
306
370
|
layoutParams = FrameLayout.LayoutParams(
|
|
307
371
|
(width / reactNativeScale).toInt(), (height / reactNativeScale).toInt()
|
|
308
372
|
)
|
|
@@ -402,10 +466,7 @@ class VirtualRenderer(
|
|
|
402
466
|
}
|
|
403
467
|
|
|
404
468
|
@MainThread
|
|
405
|
-
private fun
|
|
406
|
-
virtualDisplay?.release()
|
|
407
|
-
virtualDisplay = null
|
|
408
|
-
|
|
469
|
+
private fun stopReactSurface() {
|
|
409
470
|
val uiManager = NitroModules.applicationContext?.let {
|
|
410
471
|
UIManagerHelper.getUIManager(
|
|
411
472
|
it, UIManagerType.FABRIC
|
|
@@ -420,6 +481,19 @@ class VirtualRenderer(
|
|
|
420
481
|
// Fabric already invalidated
|
|
421
482
|
} finally {
|
|
422
483
|
reactSurfaceId = null
|
|
484
|
+
reactSurfaceView = null
|
|
485
|
+
reactSurfaceImpl = null
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
@MainThread
|
|
490
|
+
private fun stop() {
|
|
491
|
+
virtualDisplay?.release()
|
|
492
|
+
virtualDisplay = null
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
stopReactSurface()
|
|
496
|
+
} finally {
|
|
423
497
|
virtualRenderer.remove(moduleName)
|
|
424
498
|
}
|
|
425
499
|
}
|
|
@@ -433,6 +507,19 @@ class VirtualRenderer(
|
|
|
433
507
|
return virtualRenderer.contains(moduleId)
|
|
434
508
|
}
|
|
435
509
|
|
|
510
|
+
fun getWindowInformation(moduleId: String): WindowInformation? {
|
|
511
|
+
val renderer = virtualRenderer[moduleId] ?: return null
|
|
512
|
+
if (renderer.width == 0 || renderer.height == 0) {
|
|
513
|
+
return null
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return WindowInformation(
|
|
517
|
+
width = (renderer.width / renderer.scale).toInt().toDouble(),
|
|
518
|
+
height = (renderer.height / renderer.scale).toInt().toDouble(),
|
|
519
|
+
scale = renderer.scale.toDouble()
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
436
523
|
fun removeRenderer(moduleId: String) {
|
|
437
524
|
virtualRenderer[moduleId]?.stop()
|
|
438
525
|
virtualRenderer.remove(moduleId)
|
package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputManager.kt
CHANGED
|
@@ -86,7 +86,7 @@ class VoiceInputManager(
|
|
|
86
86
|
throw IllegalStateException("Audio focus request denied")
|
|
87
87
|
}
|
|
88
88
|
try {
|
|
89
|
-
startSoundUri?.let { playSound(
|
|
89
|
+
val startSoundJob = startSoundUri?.let { uri -> scope.launch { playSound(uri) } }
|
|
90
90
|
val result = if (preferSpeechToText) {
|
|
91
91
|
val context = NitroModules.applicationContext ?: throw IllegalArgumentException()
|
|
92
92
|
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
|
@@ -106,6 +106,7 @@ class VoiceInputManager(
|
|
|
106
106
|
} else {
|
|
107
107
|
startPCM(silenceThresholdMs, maxDurationMs, onChunk)
|
|
108
108
|
}
|
|
109
|
+
startSoundJob?.join()
|
|
109
110
|
if (cancelledByUser) throw VoiceInputCancelledException()
|
|
110
111
|
endSoundUri?.let { playSound(it) }
|
|
111
112
|
return result
|
|
@@ -3,9 +3,7 @@ import CarPlay
|
|
|
3
3
|
import NitroModules
|
|
4
4
|
import Speech
|
|
5
5
|
|
|
6
|
-
///
|
|
7
|
-
/// Needed because AVAudioPlayer.delegate is weak, so without an external strong
|
|
8
|
-
/// reference the delegate (and player) would be released immediately after play().
|
|
6
|
+
/// Retains the player and itself until playback finishes — AVAudioPlayer.delegate is weak.
|
|
9
7
|
private final class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate, @unchecked Sendable {
|
|
10
8
|
private let onFinish: () -> Void
|
|
11
9
|
private var keepAlive: AudioPlayerDelegate?
|
|
@@ -28,8 +26,7 @@ private final class AudioPlayerDelegate: NSObject, AVAudioPlayerDelegate, @unche
|
|
|
28
26
|
func audioPlayerDecodeErrorDidOccur(_: AVAudioPlayer, error _: Error?) { finish() }
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
///
|
|
32
|
-
/// shared between a stop() call and an async recognition task callback.
|
|
29
|
+
/// CheckedContinuation wrapper that can only be resumed once, safe across concurrent stop() and recognition callbacks.
|
|
33
30
|
private final class ResultBox: @unchecked Sendable {
|
|
34
31
|
private var continuation: CheckedContinuation<VoiceInputResult, Error>?
|
|
35
32
|
private let lock = NSLock()
|
|
@@ -53,8 +50,7 @@ private final class ResultBox: @unchecked Sendable {
|
|
|
53
50
|
}
|
|
54
51
|
}
|
|
55
52
|
|
|
56
|
-
///
|
|
57
|
-
/// or transcribes it via SFSpeechRecognizer when preferSpeechToText is true.
|
|
53
|
+
/// Records 16 kHz / 16-bit mono PCM from the car mic, or transcribes via SFSpeechRecognizer.
|
|
58
54
|
class VoiceInputManager {
|
|
59
55
|
private var audioEngine: AVAudioEngine?
|
|
60
56
|
private var voiceControlTemplate: CPVoiceControlTemplate?
|
|
@@ -62,7 +58,6 @@ class VoiceInputManager {
|
|
|
62
58
|
private var samples: [Int16] = []
|
|
63
59
|
private var isStopping = false
|
|
64
60
|
private var cancelledByUser = false
|
|
65
|
-
private var isIgnoringSamples = false
|
|
66
61
|
private let stopLock = NSLock()
|
|
67
62
|
|
|
68
63
|
// STT
|
|
@@ -72,6 +67,7 @@ class VoiceInputManager {
|
|
|
72
67
|
// Timing
|
|
73
68
|
private var recordingStart: Date?
|
|
74
69
|
private var silenceStart: Date?
|
|
70
|
+
private var firstBufferContinuation: CheckedContinuation<Void, Never>?
|
|
75
71
|
|
|
76
72
|
private static let sampleRate: Double = 16_000
|
|
77
73
|
private static let tapBufferSize: AVAudioFrameCount = 4_096
|
|
@@ -103,6 +99,13 @@ class VoiceInputManager {
|
|
|
103
99
|
stopLock.withLock {
|
|
104
100
|
cancelledByUser = false
|
|
105
101
|
}
|
|
102
|
+
// Single session for the full flow (start sound + recording + end sound); defer deactivates once at the end.
|
|
103
|
+
let session = AVAudioSession.sharedInstance()
|
|
104
|
+
try session.setCategory(.playAndRecord, mode: .measurement, options: [])
|
|
105
|
+
try session.setActive(true)
|
|
106
|
+
defer {
|
|
107
|
+
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
108
|
+
}
|
|
106
109
|
|
|
107
110
|
let result = try await withCheckedThrowingContinuation { cont in
|
|
108
111
|
let box = ResultBox(cont)
|
|
@@ -128,12 +131,17 @@ class VoiceInputManager {
|
|
|
128
131
|
return
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
// Start sound fires immediately; template is deferred until the first tap buffer so the mic indicator is already on.
|
|
135
|
+
if let uri = startSoundUri {
|
|
136
|
+
Task { await self.playSound(uri: uri) }
|
|
137
|
+
}
|
|
138
|
+
if let interfaceController = interfaceController {
|
|
139
|
+
Task {
|
|
140
|
+
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
141
|
+
self.stopLock.withLock { self.firstBufferContinuation = cont }
|
|
142
|
+
}
|
|
143
|
+
// Skip if stop() fired before the first buffer — cleanup already dismissed.
|
|
144
|
+
guard !self.stopLock.withLock({ self.isStopping }) else { return }
|
|
137
145
|
await self.presentVoiceTemplate(
|
|
138
146
|
interfaceController: interfaceController,
|
|
139
147
|
listeningText: listeningText,
|
|
@@ -141,13 +149,6 @@ class VoiceInputManager {
|
|
|
141
149
|
listeningImageRepeats: listeningImageRepeats
|
|
142
150
|
)
|
|
143
151
|
}
|
|
144
|
-
if let uri = startSoundUri {
|
|
145
|
-
await self.playSound(uri: uri, setupSession: false)
|
|
146
|
-
}
|
|
147
|
-
self.stopLock.withLock {
|
|
148
|
-
self.isIgnoringSamples = false
|
|
149
|
-
self.recordingStart = Date()
|
|
150
|
-
}
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -158,14 +159,9 @@ class VoiceInputManager {
|
|
|
158
159
|
return result
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
private func playSound(uri: String
|
|
162
|
+
private func playSound(uri: String) async {
|
|
162
163
|
guard let url = URL(string: uri) else { return }
|
|
163
164
|
do {
|
|
164
|
-
let session = AVAudioSession.sharedInstance()
|
|
165
|
-
if setupSession {
|
|
166
|
-
try session.setCategory(.playback, mode: .default)
|
|
167
|
-
try session.setActive(true)
|
|
168
|
-
}
|
|
169
165
|
// URLSession handles both http:// (Metro dev server) and file:// (release bundle)
|
|
170
166
|
let (data, _) = try await URLSession.shared.data(from: url)
|
|
171
167
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
@@ -206,8 +202,7 @@ class VoiceInputManager {
|
|
|
206
202
|
stopLock.unlock()
|
|
207
203
|
|
|
208
204
|
if wasSTTMode {
|
|
209
|
-
// endAudio()
|
|
210
|
-
// which resumes the box. Engine teardown happens there too.
|
|
205
|
+
// endAudio() triggers the final recognition result, which resumes the box and tears down the engine.
|
|
211
206
|
capturedRequest?.endAudio()
|
|
212
207
|
}
|
|
213
208
|
else {
|
|
@@ -236,10 +231,6 @@ class VoiceInputManager {
|
|
|
236
231
|
throw VoiceInputError.microphonePermissionDenied
|
|
237
232
|
}
|
|
238
233
|
|
|
239
|
-
let session = AVAudioSession.sharedInstance()
|
|
240
|
-
try session.setCategory(.playAndRecord, mode: .measurement, options: [])
|
|
241
|
-
try session.setActive(true)
|
|
242
|
-
|
|
243
234
|
var activeRecognitionRequest: SFSpeechAudioBufferRecognitionRequest? = nil
|
|
244
235
|
|
|
245
236
|
if preferSpeechToText, SFSpeechRecognizer.authorizationStatus() == .authorized,
|
|
@@ -311,9 +302,9 @@ class VoiceInputManager {
|
|
|
311
302
|
throw VoiceInputError.converterUnavailable
|
|
312
303
|
}
|
|
313
304
|
|
|
314
|
-
recordingStart =
|
|
305
|
+
recordingStart = Date()
|
|
315
306
|
silenceStart = nil
|
|
316
|
-
|
|
307
|
+
firstBufferContinuation = nil
|
|
317
308
|
|
|
318
309
|
inputNode.installTap(
|
|
319
310
|
onBus: 0,
|
|
@@ -324,11 +315,14 @@ class VoiceInputManager {
|
|
|
324
315
|
|
|
325
316
|
self.stopLock.lock()
|
|
326
317
|
let stopping = self.isStopping
|
|
327
|
-
let ignoringSamples = self.isIgnoringSamples
|
|
328
318
|
let recordingStartSnapshot = self.recordingStart
|
|
319
|
+
let firstBufferCont = self.firstBufferContinuation
|
|
320
|
+
self.firstBufferContinuation = nil
|
|
329
321
|
self.stopLock.unlock()
|
|
330
322
|
|
|
331
|
-
|
|
323
|
+
firstBufferCont?.resume()
|
|
324
|
+
|
|
325
|
+
guard !stopping else { return }
|
|
332
326
|
|
|
333
327
|
// Feed STT if active
|
|
334
328
|
activeRecognitionRequest?.append(buffer)
|
|
@@ -378,8 +372,7 @@ class VoiceInputManager {
|
|
|
378
372
|
return
|
|
379
373
|
}
|
|
380
374
|
|
|
381
|
-
// Silence detection — skip during warm-up
|
|
382
|
-
// to stabilise before we start measuring amplitude
|
|
375
|
+
// Silence detection — skip during warm-up to let the pipeline stabilise.
|
|
383
376
|
if let start = recordingStartSnapshot,
|
|
384
377
|
now.timeIntervalSince(start) * 1000 >= VoiceInputManager.warmupMs
|
|
385
378
|
{
|
|
@@ -417,7 +410,13 @@ class VoiceInputManager {
|
|
|
417
410
|
recognitionRequest = nil
|
|
418
411
|
recordingStart = nil
|
|
419
412
|
silenceStart = nil
|
|
420
|
-
|
|
413
|
+
// Drain firstBufferContinuation so the template Task doesn't hang if stop() fired before the first buffer.
|
|
414
|
+
let pendingCont = stopLock.withLock { () -> CheckedContinuation<Void, Never>? in
|
|
415
|
+
let c = firstBufferContinuation
|
|
416
|
+
firstBufferContinuation = nil
|
|
417
|
+
return c
|
|
418
|
+
}
|
|
419
|
+
pendingCont?.resume()
|
|
421
420
|
if let interfaceController {
|
|
422
421
|
dismissVoiceTemplate(interfaceController: interfaceController)
|
|
423
422
|
}
|
|
@@ -432,15 +431,10 @@ class VoiceInputManager {
|
|
|
432
431
|
// CPVoiceControlState enforces a maximum image size of 150x150 points.
|
|
433
432
|
private static let voiceImageMaxSize = CGSize(width: 150, height: 150)
|
|
434
433
|
|
|
435
|
-
// CPVoiceControlState
|
|
436
|
-
// by the system regardless of what we pass, so we only need to clamp our own ceiling.
|
|
434
|
+
// CPVoiceControlState enforces a 0.3s–5s animation cycle; the 0.3s floor is system-applied, clamp only the ceiling.
|
|
437
435
|
private static let maxVoiceImageCycleDuration: TimeInterval = 5.0
|
|
438
436
|
|
|
439
|
-
//
|
|
440
|
-
// via CGImage during scale adjustment. Parser.decodeImage preserves animation frames by
|
|
441
|
-
// walking every frame in the source via ImageIO — UIImage(data:) never builds a multi-frame
|
|
442
|
-
// .images array itself, for GIF, APNG, or WebP. Tinting is skipped for animated images since
|
|
443
|
-
// frames cannot be tinted individually.
|
|
437
|
+
// Uses Parser.decodeImage instead of RCTConvert to preserve animation frames for GIF/APNG/WebP.
|
|
444
438
|
private func loadVoiceImage(image: Variant_GlyphImage_AssetImage_RemoteImage?, traitCollection: UITraitCollection)
|
|
445
439
|
-> UIImage?
|
|
446
440
|
{
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
children: React.ReactNode;
|
|
3
|
+
moduleName: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* renders the passed children when the specified scene/screen appeared
|
|
7
|
+
* this makes sure child hooks are executed only when the map template is ready
|
|
8
|
+
*/
|
|
9
|
+
export default function OnAppearedChildRenderer({ children, moduleName }: Props): import("react").ReactNode;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { HybridAutoPlay } from '../hybrid/HybridAutoPlay';
|
|
3
|
+
/**
|
|
4
|
+
* renders the passed children when the specified scene/screen appeared
|
|
5
|
+
* this makes sure child hooks are executed only when the map template is ready
|
|
6
|
+
*/
|
|
7
|
+
export default function OnAppearedChildRenderer({ children, moduleName }) {
|
|
8
|
+
const [didAppear, setDidAppear] = useState(false);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
let remove = HybridAutoPlay.addListenerRenderState(moduleName, (renderState) => {
|
|
11
|
+
if (renderState === 'didAppear') {
|
|
12
|
+
remove?.();
|
|
13
|
+
remove = null;
|
|
14
|
+
setDidAppear(true);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
return () => {
|
|
18
|
+
remove?.();
|
|
19
|
+
remove = null;
|
|
20
|
+
};
|
|
21
|
+
}, [moduleName]);
|
|
22
|
+
if (didAppear) {
|
|
23
|
+
return children;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { RootComponentInitialProps } from '../types/RootComponent';
|
|
3
|
+
/**
|
|
4
|
+
* Renders a user-provided root component and keeps its `window` prop up to date when the host
|
|
5
|
+
* recreates the render surface with new dimensions (some head units run Android Auto windowed
|
|
6
|
+
* and resize it at runtime), so consumers can simply read `props.window` without registering a
|
|
7
|
+
* listener themselves.
|
|
8
|
+
* On iOS this only passes the initial props through — CarPlay windows keep their size for the
|
|
9
|
+
* lifetime of a scene, so there is nothing to subscribe to.
|
|
10
|
+
*/
|
|
11
|
+
export declare function WindowInformationWrapper({ moduleName, component, componentProps, }: {
|
|
12
|
+
moduleName: string;
|
|
13
|
+
component: React.ComponentType<RootComponentInitialProps>;
|
|
14
|
+
componentProps: RootComponentInitialProps;
|
|
15
|
+
}): React.ReactElement<RootComponentInitialProps, string | React.JSXElementConstructor<any>>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createElement, useEffect, useState } from 'react';
|
|
2
|
+
import { HybridAndroidWindowInformation } from '../hybrid/HybridAndroidWindowInformation';
|
|
3
|
+
/**
|
|
4
|
+
* Renders a user-provided root component and keeps its `window` prop up to date when the host
|
|
5
|
+
* recreates the render surface with new dimensions (some head units run Android Auto windowed
|
|
6
|
+
* and resize it at runtime), so consumers can simply read `props.window` without registering a
|
|
7
|
+
* listener themselves.
|
|
8
|
+
* On iOS this only passes the initial props through — CarPlay windows keep their size for the
|
|
9
|
+
* lifetime of a scene, so there is nothing to subscribe to.
|
|
10
|
+
*/
|
|
11
|
+
export function WindowInformationWrapper({ moduleName, component, componentProps, }) {
|
|
12
|
+
const [window, setWindow] = useState(componentProps.window);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
return HybridAndroidWindowInformation?.addWindowInformationListener(moduleName, setWindow);
|
|
15
|
+
}, [moduleName]);
|
|
16
|
+
return createElement(component, { ...componentProps, window });
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A hook to determine if the CarPlay/Android Auto screen is currently focused (visible).
|
|
3
|
+
*
|
|
4
|
+
* @param moduleName The name of the module to listen to.
|
|
5
|
+
* @returns `true` if the screen is focused, `false` otherwise.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useIsAutoPlayFocused(moduleName: string): boolean;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { HybridAutoPlay } from '..';
|
|
3
|
+
/**
|
|
4
|
+
* A hook to determine if the CarPlay/Android Auto screen is currently focused (visible).
|
|
5
|
+
*
|
|
6
|
+
* @param moduleName The name of the module to listen to.
|
|
7
|
+
* @returns `true` if the screen is focused, `false` otherwise.
|
|
8
|
+
*/
|
|
9
|
+
export function useIsAutoPlayFocused(moduleName) {
|
|
10
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const remove = HybridAutoPlay.addListenerRenderState(moduleName, (state) => {
|
|
13
|
+
setIsFocused(state === 'didAppear');
|
|
14
|
+
});
|
|
15
|
+
return () => {
|
|
16
|
+
remove();
|
|
17
|
+
};
|
|
18
|
+
}, [moduleName]);
|
|
19
|
+
return isFocused;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const HybridAndroidWindowInformation = null;
|
package/lib/hybrid.d.ts
ADDED
package/lib/hybrid.js
ADDED