@iternio/react-native-auto-play 0.3.11 → 0.3.12
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 +60 -2
- package/android/src/automotive/AndroidManifest.xml +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlay.kt +91 -0
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputManager.kt +214 -0
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/template/MapTemplate.kt +2 -1
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/template/Parser.kt +108 -38
- package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/utils/BitmapCache.kt +14 -0
- package/ios/extensions/NitroImageExtensions.swift +10 -1
- package/ios/hybrid/HybridAutoPlay.swift +51 -4
- package/ios/templates/GridTemplate.swift +7 -0
- package/ios/templates/MapTemplate.swift +14 -0
- package/ios/templates/Parser.swift +91 -4
- package/ios/utils/VoiceInputManager.swift +233 -0
- package/lib/specs/AutoPlay.nitro.d.ts +31 -1
- package/lib/types/Image.d.ts +13 -0
- package/lib/utils/NitroImage.d.ts +6 -1
- package/lib/utils/NitroImage.js +7 -0
- package/nitrogen/generated/android/ReactNativeAutoPlay+autolinking.cmake +1 -1
- package/nitrogen/generated/android/c++/JGridTemplateConfig.hpp +3 -1
- package/nitrogen/generated/android/c++/JHybridAutoPlaySpec.cpp +48 -1
- package/nitrogen/generated/android/c++/JHybridAutoPlaySpec.hpp +4 -0
- package/nitrogen/generated/android/c++/JHybridClusterSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JHybridGridTemplateSpec.cpp +5 -1
- package/nitrogen/generated/android/c++/JHybridInformationTemplateSpec.cpp +5 -1
- package/nitrogen/generated/android/c++/JHybridListTemplateSpec.cpp +5 -1
- package/nitrogen/generated/android/c++/JHybridMapTemplateSpec.cpp +5 -1
- package/nitrogen/generated/android/c++/JHybridMessageTemplateSpec.cpp +5 -1
- package/nitrogen/generated/android/c++/JHybridSearchTemplateSpec.cpp +5 -1
- package/nitrogen/generated/android/c++/JHybridSignInTemplateSpec.cpp +5 -1
- package/nitrogen/generated/android/c++/JImageLane.hpp +2 -0
- package/nitrogen/generated/android/c++/JInformationTemplateConfig.hpp +3 -1
- package/nitrogen/generated/android/c++/JLaneGuidance.hpp +2 -0
- package/nitrogen/generated/android/c++/JListTemplateConfig.hpp +3 -1
- package/nitrogen/generated/android/c++/JMapTemplateConfig.hpp +3 -1
- package/nitrogen/generated/android/c++/JMessageTemplateConfig.hpp +7 -5
- package/nitrogen/generated/android/c++/JNitroAction.hpp +7 -5
- package/nitrogen/generated/android/c++/JNitroAttributedString.hpp +2 -0
- package/nitrogen/generated/android/c++/JNitroAttributedStringImage.hpp +2 -0
- package/nitrogen/generated/android/c++/JNitroBaseMapTemplateConfig.hpp +3 -1
- package/nitrogen/generated/android/c++/JNitroGridButton.hpp +2 -0
- package/nitrogen/generated/android/c++/JNitroImage.cpp +6 -2
- package/nitrogen/generated/android/c++/JNitroImage.hpp +19 -2
- package/nitrogen/generated/android/c++/JNitroManeuver.hpp +3 -1
- package/nitrogen/generated/android/c++/JNitroMapButton.hpp +2 -0
- package/nitrogen/generated/android/c++/JNitroMessageManeuver.hpp +7 -5
- package/nitrogen/generated/android/c++/JNitroNavigationAlert.hpp +7 -5
- package/nitrogen/generated/android/c++/JNitroRoutingManeuver.hpp +7 -5
- package/nitrogen/generated/android/c++/JNitroRow.hpp +7 -5
- package/nitrogen/generated/android/c++/JNitroSection.hpp +3 -1
- package/nitrogen/generated/android/c++/JPreferredImageLane.hpp +2 -0
- package/nitrogen/generated/android/c++/JRemoteImage.hpp +68 -0
- package/nitrogen/generated/android/c++/JSearchTemplateConfig.hpp +3 -1
- package/nitrogen/generated/android/c++/JSignInTemplateConfig.hpp +3 -1
- package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage_RemoteImage.cpp +30 -0
- package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage_RemoteImage.hpp +92 -0
- package/nitrogen/generated/android/c++/JVariant_PreferredImageLane_ImageLane.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlaySpec.kt +17 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/MessageTemplateConfig.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroAction.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroImage.kt +14 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroMessageManeuver.kt +2 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroNavigationAlert.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroRoutingManeuver.kt +2 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/NitroRow.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/RemoteImage.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/{Variant_GlyphImage_AssetImage.kt → Variant_GlyphImage_AssetImage_RemoteImage.kt} +20 -8
- package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.cpp +16 -8
- package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.hpp +156 -79
- package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Umbrella.hpp +4 -0
- package/nitrogen/generated/ios/c++/HybridAutoPlaySpecSwift.hpp +37 -0
- package/nitrogen/generated/ios/c++/HybridCarPlayDashboardSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridClusterSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridGridTemplateSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridInformationTemplateSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridListTemplateSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridMapTemplateSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridMessageTemplateSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridSearchTemplateSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/Func_void_std__shared_ptr_ArrayBuffer_.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridAutoPlaySpec.swift +4 -0
- package/nitrogen/generated/ios/swift/HybridAutoPlaySpec_cxx.swift +82 -0
- package/nitrogen/generated/ios/swift/ImageLane.swift +9 -4
- package/nitrogen/generated/ios/swift/MessageTemplateConfig.swift +16 -11
- package/nitrogen/generated/ios/swift/NitroAction.swift +16 -11
- package/nitrogen/generated/ios/swift/NitroAttributedStringImage.swift +9 -4
- package/nitrogen/generated/ios/swift/NitroCarPlayDashboardButton.swift +9 -4
- package/nitrogen/generated/ios/swift/NitroGridButton.swift +9 -4
- package/nitrogen/generated/ios/swift/NitroImage.swift +2 -1
- package/nitrogen/generated/ios/swift/NitroMapButton.swift +9 -4
- package/nitrogen/generated/ios/swift/NitroMessageManeuver.swift +16 -11
- package/nitrogen/generated/ios/swift/NitroNavigationAlert.swift +16 -11
- package/nitrogen/generated/ios/swift/NitroRoutingManeuver.swift +25 -15
- package/nitrogen/generated/ios/swift/NitroRow.swift +16 -11
- package/nitrogen/generated/ios/swift/PreferredImageLane.swift +9 -4
- package/nitrogen/generated/ios/swift/RemoteImage.swift +58 -0
- package/nitrogen/generated/ios/swift/{Variant_GlyphImage_AssetImage.swift → Variant_GlyphImage_AssetImage_RemoteImage.swift} +4 -3
- package/nitrogen/generated/shared/c++/HybridAutoPlaySpec.cpp +4 -0
- package/nitrogen/generated/shared/c++/HybridAutoPlaySpec.hpp +5 -0
- package/nitrogen/generated/shared/c++/ImageLane.hpp +8 -5
- package/nitrogen/generated/shared/c++/MessageTemplateConfig.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroAction.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroAttributedStringImage.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroCarPlayDashboardButton.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroGridButton.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroMapButton.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroMessageManeuver.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroNavigationAlert.hpp +8 -5
- package/nitrogen/generated/shared/c++/NitroRoutingManeuver.hpp +12 -9
- package/nitrogen/generated/shared/c++/NitroRow.hpp +8 -5
- package/nitrogen/generated/shared/c++/PreferredImageLane.hpp +8 -5
- package/nitrogen/generated/shared/c++/RemoteImage.hpp +94 -0
- package/package.json +1 -1
- package/src/specs/AutoPlay.nitro.ts +39 -1
- package/src/types/Image.ts +14 -0
- package/src/utils/NitroImage.ts +15 -1
- package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage.cpp +0 -26
- package/nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage.hpp +0 -75
package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/utils/BitmapCache.kt
CHANGED
|
@@ -6,6 +6,7 @@ import androidx.car.app.CarContext
|
|
|
6
6
|
import com.margelo.nitro.swe.iternio.reactnativeautoplay.AssetImage
|
|
7
7
|
import com.margelo.nitro.swe.iternio.reactnativeautoplay.GlyphImage
|
|
8
8
|
import com.margelo.nitro.swe.iternio.reactnativeautoplay.NitroColor
|
|
9
|
+
import com.margelo.nitro.swe.iternio.reactnativeautoplay.RemoteImage
|
|
9
10
|
|
|
10
11
|
object BitmapCache {
|
|
11
12
|
private val maxMemory = Runtime.getRuntime().maxMemory()
|
|
@@ -37,6 +38,16 @@ object BitmapCache {
|
|
|
37
38
|
put(key, bitmap)
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
fun get(context: CarContext, image: RemoteImage): Bitmap? {
|
|
42
|
+
val key = image.cacheKey(context)
|
|
43
|
+
return get(key)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fun put(context: CarContext, image: RemoteImage, bitmap: Bitmap) {
|
|
47
|
+
val key = image.cacheKey(context)
|
|
48
|
+
put(key, bitmap)
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
private fun get(key: String): Bitmap? {
|
|
41
52
|
synchronized(bitmapCache) {
|
|
42
53
|
return bitmapCache.get(key)
|
|
@@ -56,5 +67,8 @@ fun NitroColor.get(context: CarContext): Int =
|
|
|
56
67
|
fun AssetImage.cacheKey(context: CarContext): String =
|
|
57
68
|
this.color?.let { "${this.uri}/${it.get(context)}" } ?: run { this.uri }
|
|
58
69
|
|
|
70
|
+
fun RemoteImage.cacheKey(context: CarContext): String =
|
|
71
|
+
this.color?.let { "${this.uri}/${it.get(context)}" } ?: run { this.uri }
|
|
72
|
+
|
|
59
73
|
fun GlyphImage.cacheKey(context: CarContext): String =
|
|
60
74
|
"$glyph/${color.get(context)}/${backgroundColor.get(context)}/$fontScale"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
protocol ImageProtocol {
|
|
9
9
|
var glyphImage: GlyphImage? { get }
|
|
10
10
|
var assetImage: AssetImage? { get }
|
|
11
|
+
var remoteImage: RemoteImage? { get }
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
extension NitroImage: ImageProtocol {
|
|
@@ -19,9 +20,13 @@ extension NitroImage: ImageProtocol {
|
|
|
19
20
|
if case .second(let asset) = self { return asset }
|
|
20
21
|
return nil
|
|
21
22
|
}
|
|
23
|
+
var remoteImage: RemoteImage? {
|
|
24
|
+
if case .third(let remote) = self { return remote }
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
extension
|
|
29
|
+
extension Variant_GlyphImage_AssetImage_RemoteImage: ImageProtocol {
|
|
25
30
|
var glyphImage: GlyphImage? {
|
|
26
31
|
if case .first(let glyph) = self { return glyph }
|
|
27
32
|
return nil
|
|
@@ -30,4 +35,8 @@ extension Variant_GlyphImage_AssetImage: ImageProtocol {
|
|
|
30
35
|
if case .second(let asset) = self { return asset }
|
|
31
36
|
return nil
|
|
32
37
|
}
|
|
38
|
+
var remoteImage: RemoteImage? {
|
|
39
|
+
if case .third(let remote) = self { return remote }
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
33
42
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import AVFoundation
|
|
1
2
|
import CarPlay
|
|
2
3
|
import NitroModules
|
|
3
4
|
|
|
@@ -20,6 +21,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
|
|
|
20
21
|
private static var listeners = [EventName: [StateListener]]()
|
|
21
22
|
private static var renderStateListeners = [String: [RenderStateListener]]()
|
|
22
23
|
private static var safeAreaInsetsListeners = [String: [SafeAreaListener]]()
|
|
24
|
+
private static var voiceInputManager: VoiceInputManager?
|
|
23
25
|
|
|
24
26
|
override init() {
|
|
25
27
|
HybridAutoPlay.listeners.removeAll()
|
|
@@ -117,10 +119,55 @@ class HybridAutoPlay: HybridAutoPlaySpec {
|
|
|
117
119
|
func addListenerVoiceInput(
|
|
118
120
|
callback: @escaping (Location?, String?) -> Void
|
|
119
121
|
) throws -> () -> Void {
|
|
120
|
-
//
|
|
122
|
+
// iOS does not use the OS-triggered voice input path — use startVoiceInput() instead.
|
|
121
123
|
return {}
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
func hasVoiceInputPermission() throws -> Bool {
|
|
127
|
+
return AVAudioSession.sharedInstance().recordPermission == .granted
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func requestVoiceInputPermission() throws -> Promise<Bool> {
|
|
131
|
+
return Promise.async {
|
|
132
|
+
return await withCheckedContinuation { cont in
|
|
133
|
+
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
134
|
+
cont.resume(returning: granted)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func startVoiceInput(silenceThresholdMs: Double?, maxDurationMs: Double?, listeningText: String?) throws -> Promise<
|
|
141
|
+
ArrayBuffer
|
|
142
|
+
> {
|
|
143
|
+
return Promise.async {
|
|
144
|
+
let interfaceController = try? await RootModule.withInterfaceController { $0 }
|
|
145
|
+
|
|
146
|
+
let manager = VoiceInputManager()
|
|
147
|
+
HybridAutoPlay.voiceInputManager = manager
|
|
148
|
+
|
|
149
|
+
defer {
|
|
150
|
+
HybridAutoPlay.voiceInputManager = nil
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let data = try await manager.start(
|
|
154
|
+
interfaceController: interfaceController,
|
|
155
|
+
silenceThresholdMs: silenceThresholdMs ?? 1_500,
|
|
156
|
+
maxDurationMs: maxDurationMs ?? 10_000,
|
|
157
|
+
listeningText: listeningText ?? "Listening..."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return try ArrayBuffer.copy(data: data)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func stopVoiceInput() throws {
|
|
165
|
+
Task { @MainActor in
|
|
166
|
+
let interfaceController = try? await RootModule.withInterfaceController { $0 }
|
|
167
|
+
HybridAutoPlay.voiceInputManager?.stop(interfaceController: interfaceController)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
124
171
|
// MARK: set/push/pop templates
|
|
125
172
|
func setRootTemplate(templateId: String) throws -> Promise<Void> {
|
|
126
173
|
return Promise.async {
|
|
@@ -151,7 +198,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
|
|
|
151
198
|
}
|
|
152
199
|
|
|
153
200
|
func pushTemplate(templateId: String) throws
|
|
154
|
-
->
|
|
201
|
+
-> Promise<Void>
|
|
155
202
|
{
|
|
156
203
|
return Promise.async {
|
|
157
204
|
try await RootModule.withSceneAndInterfaceController {
|
|
@@ -201,7 +248,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
|
|
|
201
248
|
}
|
|
202
249
|
}
|
|
203
250
|
|
|
204
|
-
func popTemplate(animate: Bool?) throws ->
|
|
251
|
+
func popTemplate(animate: Bool?) throws -> Promise<Void> {
|
|
205
252
|
return Promise.async {
|
|
206
253
|
try await RootModule.withInterfaceController {
|
|
207
254
|
interfaceController in
|
|
@@ -222,7 +269,7 @@ class HybridAutoPlay: HybridAutoPlaySpec {
|
|
|
222
269
|
}
|
|
223
270
|
}
|
|
224
271
|
|
|
225
|
-
func popToRootTemplate(animate: Bool?) throws ->
|
|
272
|
+
func popToRootTemplate(animate: Bool?) throws -> Promise<Void> {
|
|
226
273
|
return Promise.async {
|
|
227
274
|
try await RootModule.withInterfaceController {
|
|
228
275
|
interfaceController in
|
|
@@ -69,6 +69,13 @@ class GridTemplate: AutoPlayHeaderProviding {
|
|
|
69
69
|
)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
if let remoteImage = button.image.remoteImage {
|
|
73
|
+
image = Parser.parseRemoteImage(
|
|
74
|
+
remoteImage: remoteImage,
|
|
75
|
+
traitCollection: traitCollection
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
72
79
|
guard let image = image else { return nil }
|
|
73
80
|
guard let title = Parser.parseText(text: button.title) else { return nil }
|
|
74
81
|
|
|
@@ -127,6 +127,20 @@ class MapTemplate: AutoPlayHeaderProviding,
|
|
|
127
127
|
button.onPress?()
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
if let remoteImage = button.image.remoteImage,
|
|
131
|
+
let icon = Parser.parseRemoteImage(
|
|
132
|
+
remoteImage: remoteImage,
|
|
133
|
+
traitCollection: traitCollection
|
|
134
|
+
)
|
|
135
|
+
{
|
|
136
|
+
return CPMapButton(image: icon) { _ in
|
|
137
|
+
if button.type == .pan {
|
|
138
|
+
self.onPanButtonPress()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
button.onPress?()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
130
144
|
|
|
131
145
|
return CPMapButton { _ in
|
|
132
146
|
if button.type == .pan {
|
|
@@ -74,6 +74,12 @@ class Parser {
|
|
|
74
74
|
traitCollection: traitCollection
|
|
75
75
|
)
|
|
76
76
|
}
|
|
77
|
+
if let remoteImage = action.image?.remoteImage {
|
|
78
|
+
image = Parser.parseRemoteImage(
|
|
79
|
+
remoteImage: remoteImage,
|
|
80
|
+
traitCollection: traitCollection
|
|
81
|
+
)
|
|
82
|
+
}
|
|
77
83
|
|
|
78
84
|
var button: CPBarButton
|
|
79
85
|
|
|
@@ -771,9 +777,30 @@ class Parser {
|
|
|
771
777
|
)
|
|
772
778
|
}
|
|
773
779
|
|
|
780
|
+
if let remoteImage = image?.remoteImage {
|
|
781
|
+
return Parser.parseRemoteImage(
|
|
782
|
+
remoteImage: remoteImage,
|
|
783
|
+
traitCollection: traitCollection
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
|
|
774
787
|
return nil
|
|
775
788
|
}
|
|
776
789
|
|
|
790
|
+
// MARK: - Remote image cache
|
|
791
|
+
private static let remoteImageCache: NSCache<NSString, UIImage> = {
|
|
792
|
+
let cache = NSCache<NSString, UIImage>()
|
|
793
|
+
cache.countLimit = 50
|
|
794
|
+
cache.totalCostLimit = 8 * 1024 * 1024 // 8 MB, matching Android's BitmapCache
|
|
795
|
+
return cache
|
|
796
|
+
}()
|
|
797
|
+
|
|
798
|
+
/// Shared session — long-lived by design; failed tasks don't invalidate it.
|
|
799
|
+
private static let remoteImageSession = URLSession(configuration: .default)
|
|
800
|
+
|
|
801
|
+
/// Default network timeout for remote images when no `timeoutMs` is provided.
|
|
802
|
+
private static let defaultRemoteTimeoutSeconds: TimeInterval = 0.5
|
|
803
|
+
|
|
777
804
|
static func parseAssetImage(
|
|
778
805
|
assetImage: AssetImage,
|
|
779
806
|
traitCollection: UITraitCollection
|
|
@@ -784,17 +811,77 @@ class Parser {
|
|
|
784
811
|
"__packager_asset": assetImage.packager_asset,
|
|
785
812
|
])
|
|
786
813
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
814
|
+
return applyTint(
|
|
815
|
+
uiImage: uiImage,
|
|
816
|
+
color: assetImage.color,
|
|
817
|
+
traitCollection: traitCollection
|
|
818
|
+
)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
static func parseRemoteImage(
|
|
822
|
+
remoteImage: RemoteImage,
|
|
823
|
+
traitCollection: UITraitCollection
|
|
824
|
+
) -> UIImage? {
|
|
825
|
+
let timeoutSeconds = remoteImage.timeoutMs.map { $0 / 1000.0 } ?? defaultRemoteTimeoutSeconds
|
|
826
|
+
let uiImage = loadRemoteImage(uri: remoteImage.uri, timeoutSeconds: timeoutSeconds)
|
|
827
|
+
|
|
828
|
+
return applyTint(
|
|
829
|
+
uiImage: uiImage,
|
|
830
|
+
color: remoteImage.color,
|
|
831
|
+
traitCollection: traitCollection
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private static func applyTint(
|
|
836
|
+
uiImage: UIImage?,
|
|
837
|
+
color: NitroColor?,
|
|
838
|
+
traitCollection: UITraitCollection
|
|
839
|
+
) -> UIImage? {
|
|
840
|
+
guard let image = uiImage else { return nil }
|
|
841
|
+
guard let color else { return image }
|
|
790
842
|
|
|
791
843
|
return getTintedImageAsset(
|
|
792
844
|
color: color,
|
|
793
|
-
uiImage:
|
|
845
|
+
uiImage: image,
|
|
794
846
|
traitCollection: traitCollection
|
|
795
847
|
)
|
|
796
848
|
}
|
|
797
849
|
|
|
850
|
+
/// Synchronously loads an image from a remote HTTPS URL with in-memory caching.
|
|
851
|
+
/// `parseRemoteImage` is always invoked on a background thread by the Car App rendering pipeline,
|
|
852
|
+
/// so the semaphore wait cannot block the main thread.
|
|
853
|
+
private static func loadRemoteImage(uri: String, timeoutSeconds: TimeInterval) -> UIImage? {
|
|
854
|
+
let cacheKey = uri as NSString
|
|
855
|
+
if let cached = remoteImageCache.object(forKey: cacheKey) {
|
|
856
|
+
return cached
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
guard let url = URL(string: uri) else { return nil }
|
|
860
|
+
|
|
861
|
+
var request = URLRequest(url: url)
|
|
862
|
+
request.timeoutInterval = timeoutSeconds
|
|
863
|
+
|
|
864
|
+
var resultData: Data?
|
|
865
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
866
|
+
let task = remoteImageSession.dataTask(with: request) { data, _, _ in
|
|
867
|
+
resultData = data
|
|
868
|
+
semaphore.signal()
|
|
869
|
+
}
|
|
870
|
+
task.resume()
|
|
871
|
+
if semaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
|
|
872
|
+
task.cancel()
|
|
873
|
+
return UIImage(systemName: "exclamationmark.circle")
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
guard let data = resultData, let image = UIImage(data: data) else {
|
|
877
|
+
return UIImage(systemName: "exclamationmark.circle")
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
|
|
881
|
+
remoteImageCache.setObject(image, forKey: cacheKey, cost: cost)
|
|
882
|
+
return image
|
|
883
|
+
}
|
|
884
|
+
|
|
798
885
|
static func getTintedImageAsset(
|
|
799
886
|
color: NitroColor,
|
|
800
887
|
uiImage: UIImage,
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CarPlay
|
|
3
|
+
|
|
4
|
+
/// Captures audio from the car microphone and buffers raw 16 kHz / 16-bit / mono PCM.
|
|
5
|
+
/// Recording stops automatically when silence is detected or the max duration is reached.
|
|
6
|
+
class VoiceInputManager {
|
|
7
|
+
private var audioEngine: AVAudioEngine?
|
|
8
|
+
private var voiceControlTemplate: CPVoiceControlTemplate?
|
|
9
|
+
private var continuation: CheckedContinuation<[Int16], Error>?
|
|
10
|
+
private var samples: [Int16] = []
|
|
11
|
+
private var isStopping = false
|
|
12
|
+
private let stopLock = NSLock()
|
|
13
|
+
|
|
14
|
+
// Timing
|
|
15
|
+
private var recordingStart: Date?
|
|
16
|
+
private var silenceStart: Date?
|
|
17
|
+
|
|
18
|
+
private static let sampleRate: Double = 16_000
|
|
19
|
+
private static let tapBufferSize: AVAudioFrameCount = 4_096
|
|
20
|
+
private static let silenceAmplitudeThreshold = 500
|
|
21
|
+
private static let warmupMs: Double = 500
|
|
22
|
+
|
|
23
|
+
private static let targetFormat = AVAudioFormat(
|
|
24
|
+
commonFormat: .pcmFormatInt16,
|
|
25
|
+
sampleRate: sampleRate,
|
|
26
|
+
channels: 1,
|
|
27
|
+
interleaved: true
|
|
28
|
+
)!
|
|
29
|
+
|
|
30
|
+
// MARK: - Public
|
|
31
|
+
|
|
32
|
+
func start(
|
|
33
|
+
interfaceController: AutoPlayInterfaceController?,
|
|
34
|
+
silenceThresholdMs: Double,
|
|
35
|
+
maxDurationMs: Double,
|
|
36
|
+
listeningText: String
|
|
37
|
+
) async throws -> Data {
|
|
38
|
+
let samples = try await withCheckedThrowingContinuation {
|
|
39
|
+
(cont: CheckedContinuation<[Int16], Error>) in
|
|
40
|
+
self.continuation = cont
|
|
41
|
+
self.samples = []
|
|
42
|
+
self.isStopping = false
|
|
43
|
+
|
|
44
|
+
do {
|
|
45
|
+
try self.startCapture(
|
|
46
|
+
interfaceController: interfaceController,
|
|
47
|
+
silenceThresholdMs: silenceThresholdMs,
|
|
48
|
+
maxDurationMs: maxDurationMs,
|
|
49
|
+
listeningText: listeningText
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
self.stopCapture(interfaceController: interfaceController)
|
|
54
|
+
self.continuation = nil
|
|
55
|
+
cont.resume(throwing: error)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return samplesAsData(samples)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func stop(interfaceController: AutoPlayInterfaceController? = nil) {
|
|
63
|
+
stopLock.lock()
|
|
64
|
+
guard !isStopping else {
|
|
65
|
+
stopLock.unlock()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
isStopping = true
|
|
69
|
+
let capturedContinuation = continuation
|
|
70
|
+
let capturedSamples = samples
|
|
71
|
+
continuation = nil
|
|
72
|
+
samples = []
|
|
73
|
+
stopLock.unlock()
|
|
74
|
+
|
|
75
|
+
stopCapture(interfaceController: interfaceController)
|
|
76
|
+
capturedContinuation?.resume(returning: capturedSamples)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// MARK: - Private
|
|
80
|
+
|
|
81
|
+
private func startCapture(
|
|
82
|
+
interfaceController: AutoPlayInterfaceController?,
|
|
83
|
+
silenceThresholdMs: Double,
|
|
84
|
+
maxDurationMs: Double,
|
|
85
|
+
listeningText: String
|
|
86
|
+
) throws {
|
|
87
|
+
guard AVAudioSession.sharedInstance().recordPermission == .granted else {
|
|
88
|
+
throw VoiceInputError.microphonePermissionDenied
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Activate the session first so inputNode reports the correct hardware format
|
|
92
|
+
let session = AVAudioSession.sharedInstance()
|
|
93
|
+
try session.setCategory(.playAndRecord, mode: .measurement, options: [.mixWithOthers])
|
|
94
|
+
try session.setActive(true)
|
|
95
|
+
|
|
96
|
+
if let interfaceController {
|
|
97
|
+
presentVoiceTemplate(interfaceController: interfaceController, listeningText: listeningText)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let engine = AVAudioEngine()
|
|
101
|
+
let inputNode = engine.inputNode
|
|
102
|
+
let nativeFormat = inputNode.outputFormat(forBus: 0)
|
|
103
|
+
|
|
104
|
+
let targetFormat = VoiceInputManager.targetFormat
|
|
105
|
+
guard let converter = AVAudioConverter(from: nativeFormat, to: targetFormat) else {
|
|
106
|
+
throw VoiceInputError.converterUnavailable
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
recordingStart = Date()
|
|
110
|
+
silenceStart = nil
|
|
111
|
+
|
|
112
|
+
inputNode.installTap(
|
|
113
|
+
onBus: 0,
|
|
114
|
+
bufferSize: VoiceInputManager.tapBufferSize,
|
|
115
|
+
format: nativeFormat
|
|
116
|
+
) { [weak self] buffer, _ in
|
|
117
|
+
guard let self, !self.isStopping else { return }
|
|
118
|
+
|
|
119
|
+
let outputFrameCapacity = AVAudioFrameCount(
|
|
120
|
+
Double(buffer.frameLength)
|
|
121
|
+
* VoiceInputManager.sampleRate
|
|
122
|
+
/ nativeFormat.sampleRate
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
guard
|
|
126
|
+
let outputBuffer = AVAudioPCMBuffer(
|
|
127
|
+
pcmFormat: targetFormat,
|
|
128
|
+
frameCapacity: outputFrameCapacity
|
|
129
|
+
)
|
|
130
|
+
else { return }
|
|
131
|
+
|
|
132
|
+
var conversionError: NSError?
|
|
133
|
+
let status = converter.convert(to: outputBuffer, error: &conversionError) {
|
|
134
|
+
_,
|
|
135
|
+
outStatus in
|
|
136
|
+
outStatus.pointee = .haveData
|
|
137
|
+
return buffer
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
guard status != .error, let int16Data = outputBuffer.int16ChannelData else { return }
|
|
141
|
+
|
|
142
|
+
let frameCount = Int(outputBuffer.frameLength)
|
|
143
|
+
let newSamples = Array(UnsafeBufferPointer(start: int16Data[0], count: frameCount))
|
|
144
|
+
self.samples.append(contentsOf: newSamples)
|
|
145
|
+
|
|
146
|
+
let now = Date()
|
|
147
|
+
|
|
148
|
+
// Max duration check
|
|
149
|
+
if let start = self.recordingStart,
|
|
150
|
+
now.timeIntervalSince(start) * 1000 >= maxDurationMs
|
|
151
|
+
{
|
|
152
|
+
self.triggerAutoStop(interfaceController: interfaceController)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Silence detection — skip during warm-up so the pipeline has time
|
|
157
|
+
// to stabilise before we start measuring amplitude
|
|
158
|
+
if let start = self.recordingStart,
|
|
159
|
+
now.timeIntervalSince(start) * 1000 >= VoiceInputManager.warmupMs
|
|
160
|
+
{
|
|
161
|
+
let peak = newSamples.reduce(0) { max($0, abs(Int($1))) }
|
|
162
|
+
if peak < VoiceInputManager.silenceAmplitudeThreshold {
|
|
163
|
+
if self.silenceStart == nil { self.silenceStart = now }
|
|
164
|
+
if let silenceBegin = self.silenceStart,
|
|
165
|
+
now.timeIntervalSince(silenceBegin) * 1000 >= silenceThresholdMs
|
|
166
|
+
{
|
|
167
|
+
self.triggerAutoStop(interfaceController: interfaceController)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
self.silenceStart = nil
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try engine.start()
|
|
177
|
+
audioEngine = engine
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func triggerAutoStop(interfaceController: AutoPlayInterfaceController?) {
|
|
181
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
182
|
+
self.stop(interfaceController: interfaceController)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func stopCapture(interfaceController: AutoPlayInterfaceController?) {
|
|
187
|
+
audioEngine?.inputNode.removeTap(onBus: 0)
|
|
188
|
+
audioEngine?.stop()
|
|
189
|
+
audioEngine = nil
|
|
190
|
+
recordingStart = nil
|
|
191
|
+
silenceStart = nil
|
|
192
|
+
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
193
|
+
if let interfaceController {
|
|
194
|
+
dismissVoiceTemplate(interfaceController: interfaceController)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private func presentVoiceTemplate(interfaceController: AutoPlayInterfaceController, listeningText: String) {
|
|
199
|
+
let listeningState = CPVoiceControlState(
|
|
200
|
+
identifier: "listening",
|
|
201
|
+
titleVariants: [listeningText],
|
|
202
|
+
image: nil,
|
|
203
|
+
repeats: true
|
|
204
|
+
)
|
|
205
|
+
let template = CPVoiceControlTemplate(voiceControlStates: [listeningState])
|
|
206
|
+
initTemplate(template: template, id: "voice-input")
|
|
207
|
+
voiceControlTemplate = template
|
|
208
|
+
|
|
209
|
+
Task { @MainActor in
|
|
210
|
+
try? await interfaceController.presentTemplate(template, animated: true)
|
|
211
|
+
template.activateVoiceControlState(withIdentifier: "listening")
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private func dismissVoiceTemplate(interfaceController: AutoPlayInterfaceController) {
|
|
216
|
+
Task { @MainActor in
|
|
217
|
+
try? await interfaceController.dismissTemplate(animated: true)
|
|
218
|
+
}
|
|
219
|
+
voiceControlTemplate = nil
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private func samplesAsData(_ samples: [Int16]) -> Data {
|
|
223
|
+
samples.withUnsafeBufferPointer { ptr in
|
|
224
|
+
Data(buffer: ptr)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
enum VoiceInputError: Error {
|
|
230
|
+
case microphonePermissionDenied
|
|
231
|
+
case converterUnavailable
|
|
232
|
+
case noActiveSession
|
|
233
|
+
}
|
|
@@ -24,12 +24,42 @@ export interface AutoPlay extends HybridObject<{
|
|
|
24
24
|
*/
|
|
25
25
|
addListenerRenderState(moduleName: string, callback: (payload: VisibilityState) => void): CleanupCallback;
|
|
26
26
|
/**
|
|
27
|
-
* Adds a listener for voice input events
|
|
27
|
+
* Adds a listener for voice input events fired by the OS (Android Auto only).
|
|
28
|
+
* On iOS this is a no-op — use startVoiceInput instead.
|
|
28
29
|
* @param callback the callback to receive the voice input
|
|
29
30
|
* @returns callback to remove the listener
|
|
30
31
|
* @namespace Android
|
|
31
32
|
*/
|
|
32
33
|
addListenerVoiceInput(callback: (coordinates: Location | undefined, query: string | undefined) => void): CleanupCallback;
|
|
34
|
+
/**
|
|
35
|
+
* Returns true if microphone permission has already been granted.
|
|
36
|
+
*/
|
|
37
|
+
hasVoiceInputPermission(): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Request microphone permission from the user.
|
|
40
|
+
* On Android: uses the car context when Android Auto is connected, otherwise
|
|
41
|
+
* falls back to the React Native application context.
|
|
42
|
+
* On iOS: uses AVAudioApplication (iOS 17+) or AVAudioSession (iOS 15–16).
|
|
43
|
+
* Returns true if permission was granted, false if denied.
|
|
44
|
+
*/
|
|
45
|
+
requestVoiceInputPermission(): Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* Start an in-app voice recording session.
|
|
48
|
+
* On Android: acquires audio focus and captures via CarAudioRecord when
|
|
49
|
+
* Android Auto is connected, otherwise uses standard AudioRecord.
|
|
50
|
+
* On iOS: presents CPVoiceControlTemplate (when a car is connected) and
|
|
51
|
+
* captures audio via AVAudioEngine.
|
|
52
|
+
* Resolves with the complete raw PCM buffer (16 kHz, 16-bit, mono) when
|
|
53
|
+
* silence is detected, the max duration is reached, or stopVoiceInput() is called.
|
|
54
|
+
* Rejects if microphone permission has not been granted or recording fails to start.
|
|
55
|
+
*/
|
|
56
|
+
startVoiceInput(silenceThresholdMs?: number, maxDurationMs?: number, listeningText?: string): Promise<ArrayBuffer>;
|
|
57
|
+
/**
|
|
58
|
+
* Stop the active voice recording session early. Causes the Promise returned
|
|
59
|
+
* by startVoiceInput() to resolve with the audio captured so far.
|
|
60
|
+
* No-op if no recording is in progress.
|
|
61
|
+
*/
|
|
62
|
+
stopVoiceInput(): void;
|
|
33
63
|
/**
|
|
34
64
|
* sets the specified template as root template, initializes a new stack
|
|
35
65
|
* Promise might contain an error message in case setting root template failed
|
package/lib/types/Image.d.ts
CHANGED
|
@@ -24,4 +24,17 @@ export type AutoImage = {
|
|
|
24
24
|
*/
|
|
25
25
|
color?: ThemedColor | string;
|
|
26
26
|
type: 'asset';
|
|
27
|
+
} | {
|
|
28
|
+
/** HTTPS URL to a remote image. HTTP is not supported (blocked by App Transport Security). */
|
|
29
|
+
uri: string;
|
|
30
|
+
/**
|
|
31
|
+
* if specified the image gets tinted, if not it will just use the original image
|
|
32
|
+
*/
|
|
33
|
+
color?: ThemedColor | string;
|
|
34
|
+
/**
|
|
35
|
+
* Network timeout in milliseconds before the remote fetch is abandoned and `null` is returned.
|
|
36
|
+
* Defaults to 500ms when not specified.
|
|
37
|
+
*/
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
type: 'remote';
|
|
27
40
|
};
|
|
@@ -11,11 +11,16 @@ interface GlyphImage {
|
|
|
11
11
|
backgroundColor: NitroColor;
|
|
12
12
|
fontScale?: number;
|
|
13
13
|
}
|
|
14
|
+
interface RemoteImage {
|
|
15
|
+
uri: string;
|
|
16
|
+
color?: NitroColor;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
14
19
|
/**
|
|
15
20
|
* we need to map the ButtonImage.name from GlyphName to
|
|
16
21
|
* the actual numeric value so we need a nitro specific type
|
|
17
22
|
*/
|
|
18
|
-
export type NitroImage = GlyphImage | AssetImage;
|
|
23
|
+
export type NitroImage = GlyphImage | AssetImage | RemoteImage;
|
|
19
24
|
declare function convert(image: AutoImage): NitroImage;
|
|
20
25
|
declare function convert(image?: AutoImage): NitroImage | undefined;
|
|
21
26
|
export declare const NitroImageUtil: {
|
package/lib/utils/NitroImage.js
CHANGED
|
@@ -14,6 +14,13 @@ function convert(image) {
|
|
|
14
14
|
fontScale,
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
if (image.type === 'remote') {
|
|
18
|
+
return {
|
|
19
|
+
uri: image.uri,
|
|
20
|
+
color: NitroColorUtil.convert(image.color),
|
|
21
|
+
timeoutMs: image.timeoutMs,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
17
24
|
// Image.resolveAssetSource is pretty terrible, it will simply return whatever object you pass it is not a number [require(...)]
|
|
18
25
|
// so the input allows all optional parameters which are returned as is even though
|
|
19
26
|
// the return type claims to not have any optional parameters...
|
|
@@ -49,7 +49,7 @@ target_sources(
|
|
|
49
49
|
../nitrogen/generated/android/c++/JHybridAndroidAutomotiveSpec.cpp
|
|
50
50
|
../nitrogen/generated/android/c++/JHybridAndroidAutoTelemetrySpec.cpp
|
|
51
51
|
../nitrogen/generated/android/c++/JHybridAutoPlaySpec.cpp
|
|
52
|
-
../nitrogen/generated/android/c++/
|
|
52
|
+
../nitrogen/generated/android/c++/JVariant_GlyphImage_AssetImage_RemoteImage.cpp
|
|
53
53
|
../nitrogen/generated/android/c++/JHybridClusterSpec.cpp
|
|
54
54
|
../nitrogen/generated/android/c++/JNitroImage.cpp
|
|
55
55
|
../nitrogen/generated/android/c++/JHybridGridTemplateSpec.cpp
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
#include "JNitroImage.hpp"
|
|
34
34
|
#include "JNitroMapButton.hpp"
|
|
35
35
|
#include "JNitroMapButtonType.hpp"
|
|
36
|
-
#include "
|
|
36
|
+
#include "JRemoteImage.hpp"
|
|
37
|
+
#include "JVariant_GlyphImage_AssetImage_RemoteImage.hpp"
|
|
37
38
|
#include "NitroAction.hpp"
|
|
38
39
|
#include "NitroActionType.hpp"
|
|
39
40
|
#include "NitroAlignment.hpp"
|
|
@@ -43,6 +44,7 @@
|
|
|
43
44
|
#include "NitroGridButton.hpp"
|
|
44
45
|
#include "NitroMapButton.hpp"
|
|
45
46
|
#include "NitroMapButtonType.hpp"
|
|
47
|
+
#include "RemoteImage.hpp"
|
|
46
48
|
#include <NitroModules/JNICallable.hpp>
|
|
47
49
|
#include <functional>
|
|
48
50
|
#include <optional>
|