@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.
Files changed (59) hide show
  1. package/README.md +2 -1
  2. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAndroidWindowInformation.kt +49 -0
  3. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlay.kt +1 -0
  4. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VirtualRenderer.kt +116 -29
  5. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputManager.kt +2 -1
  6. package/ios/utils/VoiceInputManager.swift +41 -47
  7. package/lib/HybridAutoPlay.d.ts +2 -0
  8. package/lib/HybridAutoPlay.js +2 -0
  9. package/lib/components/OnAppearedChildRenderer.d.ts +10 -0
  10. package/lib/components/OnAppearedChildRenderer.js +26 -0
  11. package/lib/components/WindowInformationWrapper.d.ts +15 -0
  12. package/lib/components/WindowInformationWrapper.js +17 -0
  13. package/lib/hooks/useIsAutoPlayFocused.d.ts +7 -0
  14. package/lib/hooks/useIsAutoPlayFocused.js +20 -0
  15. package/lib/hybrid/HybridAndroidWindowInformation.android.d.ts +2 -0
  16. package/lib/hybrid/HybridAndroidWindowInformation.android.js +2 -0
  17. package/lib/hybrid/HybridAndroidWindowInformation.d.ts +2 -0
  18. package/lib/hybrid/HybridAndroidWindowInformation.js +1 -0
  19. package/lib/hybrid.d.ts +2 -0
  20. package/lib/hybrid.js +2 -0
  21. package/lib/index.d.ts +2 -1
  22. package/lib/index.js +2 -1
  23. package/lib/scenes/AutoPlayCluster.js +6 -1
  24. package/lib/specs/AndroidWindowInformation.nitro.d.ts +22 -0
  25. package/lib/specs/AndroidWindowInformation.nitro.js +1 -0
  26. package/lib/specs/AutomotivePermissionRequestTemplate.d.ts +11 -0
  27. package/lib/specs/AutomotivePermissionRequestTemplate.js +1 -0
  28. package/lib/specs/AutomotivePermissionRequestTemplate.nitro.d.ts +11 -0
  29. package/lib/specs/AutomotivePermissionRequestTemplate.nitro.js +1 -0
  30. package/lib/templates/AutomotivePermissionRequestTemplate.d.ts +23 -0
  31. package/lib/templates/AutomotivePermissionRequestTemplate.js +18 -0
  32. package/lib/templates/MapTemplate.js +6 -1
  33. package/lib/types/Glyphmap.d.ts +4105 -0
  34. package/lib/types/Glyphmap.js +4105 -0
  35. package/lib/types/RootComponent.d.ts +4 -0
  36. package/lib/utils/NitroOptionsPanel.d.ts +67 -0
  37. package/lib/utils/NitroOptionsPanel.js +48 -0
  38. package/nitro.json +6 -0
  39. package/nitrogen/generated/android/ReactNativeAutoPlay+autolinking.cmake +2 -0
  40. package/nitrogen/generated/android/ReactNativeAutoPlayOnLoad.cpp +18 -0
  41. package/nitrogen/generated/android/c++/JFunc_void_WindowInformation.hpp +77 -0
  42. package/nitrogen/generated/android/c++/JHybridAndroidWindowInformationSpec.cpp +68 -0
  43. package/nitrogen/generated/android/c++/JHybridAndroidWindowInformationSpec.hpp +63 -0
  44. package/nitrogen/generated/android/c++/JWindowInformation.hpp +65 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/Func_void_WindowInformation.kt +80 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAndroidWindowInformationSpec.kt +59 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/WindowInformation.kt +61 -0
  48. package/nitrogen/generated/shared/c++/HybridAndroidWindowInformationSpec.cpp +21 -0
  49. package/nitrogen/generated/shared/c++/HybridAndroidWindowInformationSpec.hpp +65 -0
  50. package/nitrogen/generated/shared/c++/WindowInformation.hpp +91 -0
  51. package/package.json +1 -1
  52. package/src/components/WindowInformationWrapper.tsx +30 -0
  53. package/src/hybrid/HybridAndroidWindowInformation.android.ts +5 -0
  54. package/src/hybrid/HybridAndroidWindowInformation.ts +3 -0
  55. package/src/index.ts +2 -1
  56. package/src/scenes/AutoPlayCluster.ts +6 -1
  57. package/src/specs/AndroidWindowInformation.nitro.ts +24 -0
  58. package/src/templates/MapTemplate.ts +6 -1
  59. 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's 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`.
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
+ }
@@ -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) {
@@ -42,20 +42,24 @@ class VirtualRenderer(
42
42
  ) {
43
43
  private var virtualDisplay: VirtualDisplay? = null
44
44
 
45
- private lateinit var reactSurfaceImpl: ReactSurfaceImpl
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 val virtualScreenDensity = context.resources.displayMetrics.density
58
- val scale = BuildConfig.SCALE_FACTOR * virtualScreenDensity
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 * context.resources.displayMetrics.density).toInt()
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
- val manager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
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
- if (!this@VirtualRenderer::reactSurfaceImpl.isInitialized) {
294
- reactSurfaceImpl = ReactSurfaceImpl(themedContext, moduleName, initialProperties)
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) getClusterSplashScreen(themedContext, height, width) else null
363
+ if (isCluster && !splashWillDisappear) {
364
+ getClusterSplashScreen(themedContext, height, width)
365
+ } else {
366
+ null
367
+ }
304
368
 
305
- val surfaceView = ReactSurfaceView(themedContext, reactSurfaceImpl).apply {
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 stop() {
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)
@@ -86,7 +86,7 @@ class VoiceInputManager(
86
86
  throw IllegalStateException("Audio focus request denied")
87
87
  }
88
88
  try {
89
- startSoundUri?.let { playSound(it) }
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
- /// Keeps itself and the player alive until playback finishes.
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
- /// Wraps CheckedContinuation so it can only be resumed once even when
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
- /// Captures audio from the car microphone and buffers raw 16 kHz / 16-bit / mono PCM,
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
- // Mic is open — present template then play start sound.
132
- // isIgnoringSamples discards tap buffers during the sound so it isn't recorded.
133
- // recordingStart is set after the sound so silence/max-duration timers are accurate.
134
- self.stopLock.withLock { self.isIgnoringSamples = startSoundUri != nil }
135
- Task {
136
- if let interfaceController = interfaceController {
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, setupSession: Bool = true) async {
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() causes the recognition task to fire its final result,
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 = nil
305
+ recordingStart = Date()
315
306
  silenceStart = nil
316
- isIgnoringSamples = false
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
- guard !stopping, !ignoringSamples else { return }
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 so the pipeline has time
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
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
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 also enforces a 0.3s–5s animation cycle; the 0.3s floor is applied
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
- // Bypasses RCTConvert for asset images: it collapses animated UIImages to a single frame
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,2 @@
1
+ import type { AutoPlay } from './specs/AutoPlay.nitro';
2
+ export declare const HybridAutoPlay: AutoPlay;
@@ -0,0 +1,2 @@
1
+ import { NitroModules } from 'react-native-nitro-modules';
2
+ export const HybridAutoPlay = NitroModules.createHybridObject('AutoPlay');
@@ -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,2 @@
1
+ import type { AndroidWindowInformation } from '../specs/AndroidWindowInformation.nitro';
2
+ export declare const HybridAndroidWindowInformation: AndroidWindowInformation | null;
@@ -0,0 +1,2 @@
1
+ import { NitroModules } from 'react-native-nitro-modules';
2
+ export const HybridAndroidWindowInformation = NitroModules.createHybridObject('AndroidWindowInformation');
@@ -0,0 +1,2 @@
1
+ import type { AndroidWindowInformation } from '../specs/AndroidWindowInformation.nitro';
2
+ export declare const HybridAndroidWindowInformation: AndroidWindowInformation | null;
@@ -0,0 +1 @@
1
+ export const HybridAndroidWindowInformation = null;
@@ -0,0 +1,2 @@
1
+ import type { AutoPlay as NitroAutoPlay } from './specs/AutoPlay.nitro';
2
+ export declare const HybridAutoPlay: NitroAutoPlay;
package/lib/hybrid.js ADDED
@@ -0,0 +1,2 @@
1
+ import { NitroModules } from 'react-native-nitro-modules';
2
+ export const HybridAutoPlay = NitroModules.createHybridObject('AutoPlay');