Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,4 @@ Conversion utilities in `src/utils/` translate TypeScript types to the NitroModu
- **TypeScript:** Strict mode with `noUnusedLocals`, `noUnusedParameters`
- Named imports enforced; no floating promises; optional chaining preferred
- `src/types/Glyphmap.ts` is excluded from linting (generated file)
- **Any language:** Always use braces for `if` statements — no single-line braceless ifs
4 changes: 2 additions & 2 deletions apps/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2624,7 +2624,7 @@ PODS:
- React-perflogger (= 0.83.5)
- React-utils (= 0.83.5)
- SocketRocket
- ReactNativeAutoPlay (0.4.7):
- ReactNativeAutoPlay (0.4.12):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2995,7 +2995,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 105856c6da6fe71c545dec3c2466ae04f25f9a73
ReactCodegen: 507d02fe6f19c44a3274adbefd38a1a252843d24
ReactCommon: 506ceb24983c6b80e0075968be21aac74a367739
ReactNativeAutoPlay: 8451b7306f94785877308f12ba104d7ada33db25
ReactNativeAutoPlay: a1ca22b8f49a95a942835e01e28238a3a5fd5049
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 897e8c1fb1fd80c3c01231ed4ec8c1ffa652c29f

Expand Down
17 changes: 13 additions & 4 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AutoPlayModules,
type CleanupCallback,
HybridAutoPlay,
HybridVoice,
} from '@iternio/react-native-auto-play';
import { Buffer } from 'buffer';
import { useEffect, useState } from 'react';
Expand Down Expand Up @@ -120,14 +121,22 @@ function AppContent() {
<Button
title="record audio"
onPress={async () => {
const granted = await HybridAutoPlay.requestVoiceInputPermission();
const granted = await HybridVoice.requestVoiceInputPermission();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just minor cosmetics, but for consistency you could use the same code style as in AutoTemplate.ts -> requestVoiceInputPermission.then(() => ...)

if (!granted) {
return;
}

const audio = await HybridAutoPlay.startVoiceInput();
console.log(`received ${audio.byteLength} bytes`);
dispatch(setRecording(Buffer.from(new Uint8Array(audio)).toString('base64')));
void HybridVoice.startVoiceInput({ preferSpeechToText: true }).then((result) => {
if (result.audio) {
console.log(`received ${result.audio.byteLength} bytes`);
dispatch(
setRecording(Buffer.from(new Uint8Array(result.audio)).toString('base64'))
);
} else if (result.transcription) {
console.log(`received ${result.transcription}`);
setSavedVoiceRecording(result.transcription);
}
});
}}
/>
<Button
Expand Down
27 changes: 19 additions & 8 deletions apps/example/src/templates/AutoTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CarPlayDashboard,
type HeaderActions,
HybridAutoPlay,
HybridVoice,
type ImageButton,
type MapTemplate,
type MapTemplateConfig,
Expand Down Expand Up @@ -242,14 +243,18 @@ const mapHeaderActions: MapTemplateConfig['headerActions'] = {
type: 'image',
image: { name: 'mic', type: 'glyph' },
onPress: () => {
void HybridAutoPlay.requestVoiceInputPermission().then((isGranted) => {
void HybridVoice.requestVoiceInputPermission().then((isGranted) => {
if (!isGranted) {
return;
}

void HybridAutoPlay.startVoiceInput().then((audio) => {
console.log(`received ${audio.byteLength} bytes`);
dispatch(setRecording(Buffer.from(new Uint8Array(audio)).toString('base64')));
void HybridVoice.startVoiceInput({ preferSpeechToText: true }).then((result) => {
if (result.audio) {
console.log(`received ${result.audio.byteLength} bytes`);
dispatch(setRecording(Buffer.from(new Uint8Array(result.audio)).toString('base64')));
} else {
console.log(`received ${result.transcription}`);
}
});
});
},
Expand Down Expand Up @@ -288,14 +293,20 @@ const mapHeaderActions: MapTemplateConfig['headerActions'] = {
type: 'glyph',
},
onPress: () => {
void HybridAutoPlay.requestVoiceInputPermission().then((isGranted) => {
void HybridVoice.requestVoiceInputPermission().then((isGranted) => {
if (!isGranted) {
return;
}

void HybridAutoPlay.startVoiceInput().then((audio) => {
console.log(`received ${audio.byteLength} bytes`);
dispatch(setRecording(Buffer.from(new Uint8Array(audio)).toString('base64')));
void HybridVoice.startVoiceInput({ preferSpeechToText: true }).then((result) => {
if (result.audio) {
console.log(`received ${result.audio.byteLength} bytes`);
dispatch(
setRecording(Buffer.from(new Uint8Array(result.audio)).toString('base64'))
);
} else {
console.log(`received ${result.transcription}`);
}
});
});
},
Expand Down
26 changes: 26 additions & 0 deletions packages/react-native-autoplay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,32 @@ The library does **not** bundle any icon font — the consuming app must provide

For cross-platform compatibility use **lowercase names with underscores only** (e.g. `material_symbols`).

**or**

1. use expo-font
```js
[
'expo-font',
{
android: {
fonts: [
{
fontFamily: 'MaterialSymbols',
fontDefinitions: [
{
path: './assets/fonts/material_symbols.ttf',
weight: 800,
},
],
},
],
},
ios: ['./assets/fonts/material_symbols.ttf'],
},
],
```
For cross-platform compatibility use **lowercase names with underscores only** (e.g. `material_symbols`).

2. Register the font and an optional glyph map at startup:

```ts
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
package com.margelo.nitro.swe.iternio.reactnativeautoplay

import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.modules.core.PermissionAwareActivity
import com.facebook.react.modules.core.PermissionListener
import com.margelo.nitro.NitroModules
import com.margelo.nitro.core.ArrayBuffer
import com.margelo.nitro.core.Promise
import com.margelo.nitro.swe.iternio.reactnativeautoplay.template.AndroidAutoTemplate
import com.margelo.nitro.swe.iternio.reactnativeautoplay.template.MessageTemplate
import com.margelo.nitro.swe.iternio.reactnativeautoplay.utils.ThreadUtil
import kotlinx.coroutines.suspendCancellableCoroutine
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.coroutines.resume
Expand Down Expand Up @@ -255,84 +247,6 @@ class HybridAutoPlay : HybridAutoPlaySpec() {
}
}

override fun hasVoiceInputPermission(): Boolean {
val context = NitroModules.applicationContext ?: return false
return ContextCompat.checkSelfPermission(
context, android.Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
}

override fun requestVoiceInputPermission(): Promise<Boolean> {
return Promise.async {
if (hasVoiceInputPermission()) {
return@async true
}

val carContext = AndroidAutoSession.getRootContext()

if (carContext != null) {
suspendCancellableCoroutine {
carContext.requestPermissions(listOf(android.Manifest.permission.RECORD_AUDIO)) { approved, _ ->
it.resume(approved.contains(android.Manifest.permission.RECORD_AUDIO))
}
}
} else {
val context = NitroModules.applicationContext ?: return@async false
val activity =
context.currentActivity as? PermissionAwareActivity ?: return@async false
val code = (Math.random() * 10000).toInt()

suspendCancellableCoroutine {
activity.requestPermissions(
arrayOf(android.Manifest.permission.RECORD_AUDIO),
code,
PermissionListener { requestCode, _, grantResults ->
if (requestCode != code) {
return@PermissionListener false
}

val granted =
grantResults.isNotEmpty() && grantResults.first() == PackageManager.PERMISSION_GRANTED

it.resume(granted)

return@PermissionListener true
})
}
}
}
}

override fun startVoiceInput(
silenceThresholdMs: Double?, maxDurationMs: Double?, listeningText: String?
): Promise<ArrayBuffer> {
return Promise.async {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
throw UnsupportedOperationException("startVoiceInput requires at least API level ${Build.VERSION_CODES.O}")
}

val manager = VoiceInputManager(AndroidAutoSession.getRootContext())
voiceInputManager = manager

try {
val pcmBytes = manager.start(
silenceThresholdMs = silenceThresholdMs?.toLong() ?: 1_500L,
maxDurationMs = maxDurationMs?.toLong() ?: 10_000L,
)
val directBuffer =
ByteBuffer.allocateDirect(pcmBytes.size).put(pcmBytes).rewind() as ByteBuffer
ArrayBuffer.wrap(directBuffer)
} finally {
voiceInputManager = null
manager.dispose()
}
}
}

override fun stopVoiceInput() {
voiceInputManager?.stop()
}

companion object {
const val TAG = "HybridAutoPlay"

Expand All @@ -343,9 +257,6 @@ class HybridAutoPlay : HybridAutoPlaySpec() {

private val voiceInputListeners = CopyOnWriteArrayList<(Location?, String?) -> Unit>()

@Volatile
private var voiceInputManager: VoiceInputManager? = null

private val safeAreaInsetsListeners =
ConcurrentHashMap<String, CopyOnWriteArrayList<(SafeAreaInsets) -> Unit>>()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.margelo.nitro.swe.iternio.reactnativeautoplay

import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.facebook.react.modules.core.PermissionAwareActivity
import com.facebook.react.modules.core.PermissionListener
import com.margelo.nitro.NitroModules
import com.margelo.nitro.core.Promise
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

class HybridVoice : HybridVoiceSpec() {
@Volatile
private var voiceInputManager: VoiceInputManager? = null

override fun hasVoiceInputPermission(): Boolean {
return VoiceInputManager.hasVoiceInputPermission()
}

override fun requestVoiceInputPermission(): Promise<Boolean> {
return Promise.async {
if (hasVoiceInputPermission()) {
return@async true
}

val carContext = AndroidAutoSession.getRootContext()

if (carContext != null) {
suspendCancellableCoroutine { cont ->
carContext.requestPermissions(
listOf(android.Manifest.permission.RECORD_AUDIO)
) { approved, _ ->
cont.resume(approved.contains(android.Manifest.permission.RECORD_AUDIO))
}
}
} else {
val context = NitroModules.applicationContext ?: return@async false
val activity =
context.currentActivity as? PermissionAwareActivity ?: return@async false
val code = (Math.random() * 10000).toInt()

suspendCancellableCoroutine { cont ->
activity.requestPermissions(
arrayOf(android.Manifest.permission.RECORD_AUDIO),
code,
PermissionListener { requestCode, _, grantResults ->
if (requestCode != code) {
return@PermissionListener false
}
cont.resume(
grantResults.isNotEmpty() &&
grantResults.first() == PackageManager.PERMISSION_GRANTED
)
true
}
)
}
}
}
}

override fun startVoiceInput(
silenceThresholdMs: Double?,
maxDurationMs: Double?,
listeningText: String?,
preferSpeechToText: Boolean?,
onChunk: ((chunk: VoiceInputChunk) -> Unit)?,
language: String?
): Promise<VoiceInputResult> {
return Promise.async {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
throw UnsupportedOperationException("startVoiceInput requires at least API level ${Build.VERSION_CODES.O}")
}

val manager = VoiceInputManager(AndroidAutoSession.getRootContext())
voiceInputManager = manager

try {
manager.start(
silenceThresholdMs = silenceThresholdMs?.toLong() ?: 1_500L,
maxDurationMs = maxDurationMs?.toLong() ?: 10_000L,
preferSpeechToText = preferSpeechToText ?: false,
onChunk = onChunk,
language = language
)
} finally {
voiceInputManager = null
manager.dispose()
}
}
}

override fun stopVoiceInput() {
voiceInputManager?.stop()
}
}
Loading
Loading