From b5e845512c871aaa5702555c7ba4e7517f3a9497 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 29 Jul 2026 15:03:48 +0200 Subject: [PATCH 1/5] feat: Add `allowHapticsAndSystemSoundsPlayback` --- .../session/CameraSessionConfiguration.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/react-native-vision-camera/src/specs/session/CameraSessionConfiguration.ts b/packages/react-native-vision-camera/src/specs/session/CameraSessionConfiguration.ts index 9f7fc2400c..22fbab7710 100644 --- a/packages/react-native-vision-camera/src/specs/session/CameraSessionConfiguration.ts +++ b/packages/react-native-vision-camera/src/specs/session/CameraSessionConfiguration.ts @@ -5,12 +5,34 @@ import type { CameraSession } from './CameraSession.nitro' */ export interface CameraSessionConfiguration { /** - * If enabled, the {@linkcode CameraSession} does not interrupt - * currently playing audio (e.g. music from a different app), - * while recording. + * If enabled, the device may play haptics/vibrations + * and system sounds during while the {@linkcode CameraSession} + * is active. + * + * If disabled (the default), all haptics and system + * sounds will be muted while the {@linkcode CameraSession} + * is active. + * + * @note If {@linkcode allowHapticsAndSystemSoundsPlayback} + * is true, haptics or system sounds may also be captured + * in Video recordings. + * Haptics may also affect stabilization or focus operations. + * + * @default false + * @platform iOS + */ + allowHapticsAndSystemSoundsPlayback?: boolean + /** + * If enabled, the device may play background audio, + * possibly from another app (e.g. Apple Music) while + * the {@linkcode CameraSession} is active. + * + * If disabled (the default), any playing audio will be + * stopped while the {@linkcode CameraSession} is active. + * + * @note If {@linkcode allowBackgroundAudioPlayback} is true, + * background audio may also be captured in Video recordings. * - * If disabled (the default), any playing audio will be stopped - * when a recording is started. * @default false * @platform iOS */ From 505b32f43dc30ed244920fb944560b876de2631a Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 29 Jul 2026 15:04:02 +0200 Subject: [PATCH 2/5] Generate specs --- .../c++/JCameraSessionConfiguration.hpp | 6 +++++- .../camera/CameraSessionConfiguration.kt | 11 +++++++--- .../swift/CameraSessionConfiguration.swift | 20 ++++++++++++++++++- .../shared/c++/CameraSessionConfiguration.hpp | 6 +++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JCameraSessionConfiguration.hpp b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JCameraSessionConfiguration.hpp index 28755cbed1..f83ed63463 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JCameraSessionConfiguration.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JCameraSessionConfiguration.hpp @@ -31,9 +31,12 @@ namespace margelo::nitro::camera { [[nodiscard]] CameraSessionConfiguration toCpp() const { static const auto clazz = javaClassStatic(); + static const auto fieldAllowHapticsAndSystemSoundsPlayback = clazz->getField("allowHapticsAndSystemSoundsPlayback"); + jni::local_ref allowHapticsAndSystemSoundsPlayback = this->getFieldValue(fieldAllowHapticsAndSystemSoundsPlayback); static const auto fieldAllowBackgroundAudioPlayback = clazz->getField("allowBackgroundAudioPlayback"); jni::local_ref allowBackgroundAudioPlayback = this->getFieldValue(fieldAllowBackgroundAudioPlayback); return CameraSessionConfiguration( + allowHapticsAndSystemSoundsPlayback != nullptr ? std::make_optional(static_cast(allowHapticsAndSystemSoundsPlayback->value())) : std::nullopt, allowBackgroundAudioPlayback != nullptr ? std::make_optional(static_cast(allowBackgroundAudioPlayback->value())) : std::nullopt ); } @@ -44,11 +47,12 @@ namespace margelo::nitro::camera { */ [[maybe_unused]] static jni::local_ref fromCpp(const CameraSessionConfiguration& value) { - using JSignature = JCameraSessionConfiguration(jni::alias_ref); + using JSignature = JCameraSessionConfiguration(jni::alias_ref, jni::alias_ref); static const auto clazz = javaClassStatic(); static const auto create = clazz->getStaticMethod("fromCpp"); return create( clazz, + value.allowHapticsAndSystemSoundsPlayback.has_value() ? jni::JBoolean::valueOf(value.allowHapticsAndSystemSoundsPlayback.value()) : nullptr, value.allowBackgroundAudioPlayback.has_value() ? jni::JBoolean::valueOf(value.allowBackgroundAudioPlayback.value()) : nullptr ); } diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/CameraSessionConfiguration.kt b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/CameraSessionConfiguration.kt index 2fe37c27fe..471fc882ff 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/CameraSessionConfiguration.kt +++ b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/CameraSessionConfiguration.kt @@ -18,6 +18,9 @@ import java.util.Objects @DoNotStrip @Keep data class CameraSessionConfiguration( + @DoNotStrip + @Keep + val allowHapticsAndSystemSoundsPlayback: Boolean?, @DoNotStrip @Keep val allowBackgroundAudioPlayback: Boolean? @@ -27,11 +30,13 @@ data class CameraSessionConfiguration( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is CameraSessionConfiguration) return false - return Objects.deepEquals(this.allowBackgroundAudioPlayback, other.allowBackgroundAudioPlayback) + return Objects.deepEquals(this.allowHapticsAndSystemSoundsPlayback, other.allowHapticsAndSystemSoundsPlayback) + && Objects.deepEquals(this.allowBackgroundAudioPlayback, other.allowBackgroundAudioPlayback) } override fun hashCode(): Int { return arrayOf( + allowHapticsAndSystemSoundsPlayback, allowBackgroundAudioPlayback ).contentDeepHashCode() } @@ -44,8 +49,8 @@ data class CameraSessionConfiguration( @Keep @Suppress("unused") @JvmStatic - private fun fromCpp(allowBackgroundAudioPlayback: Boolean?): CameraSessionConfiguration { - return CameraSessionConfiguration(allowBackgroundAudioPlayback) + private fun fromCpp(allowHapticsAndSystemSoundsPlayback: Boolean?, allowBackgroundAudioPlayback: Boolean?): CameraSessionConfiguration { + return CameraSessionConfiguration(allowHapticsAndSystemSoundsPlayback, allowBackgroundAudioPlayback) } } } diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/CameraSessionConfiguration.swift b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/CameraSessionConfiguration.swift index dc7a4798d8..9fe665f236 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/CameraSessionConfiguration.swift +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/CameraSessionConfiguration.swift @@ -18,8 +18,14 @@ public extension CameraSessionConfiguration { /** * Create a new instance of `CameraSessionConfiguration`. */ - init(allowBackgroundAudioPlayback: Bool?) { + init(allowHapticsAndSystemSoundsPlayback: Bool?, allowBackgroundAudioPlayback: Bool?) { self.init({ () -> bridge.std__optional_bool_ in + if let __unwrappedValue = allowHapticsAndSystemSoundsPlayback { + return bridge.create_std__optional_bool_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_bool_ in if let __unwrappedValue = allowBackgroundAudioPlayback { return bridge.create_std__optional_bool_(__unwrappedValue) } else { @@ -28,6 +34,18 @@ public extension CameraSessionConfiguration { }()) } + @inline(__always) + var allowHapticsAndSystemSoundsPlayback: Bool? { + return { () -> Bool? in + if bridge.has_value_std__optional_bool_(self.__allowHapticsAndSystemSoundsPlayback) { + let __unwrapped = bridge.get_std__optional_bool_(self.__allowHapticsAndSystemSoundsPlayback) + return __unwrapped + } else { + return nil + } + }() + } + @inline(__always) var allowBackgroundAudioPlayback: Bool? { return { () -> Bool? in diff --git a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/CameraSessionConfiguration.hpp b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/CameraSessionConfiguration.hpp index 1f8a422ded..c123f12147 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/CameraSessionConfiguration.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/CameraSessionConfiguration.hpp @@ -39,11 +39,12 @@ namespace margelo::nitro::camera { */ struct CameraSessionConfiguration final { public: + std::optional allowHapticsAndSystemSoundsPlayback SWIFT_PRIVATE; std::optional allowBackgroundAudioPlayback SWIFT_PRIVATE; public: CameraSessionConfiguration() = default; - explicit CameraSessionConfiguration(std::optional allowBackgroundAudioPlayback): allowBackgroundAudioPlayback(allowBackgroundAudioPlayback) {} + explicit CameraSessionConfiguration(std::optional allowHapticsAndSystemSoundsPlayback, std::optional allowBackgroundAudioPlayback): allowHapticsAndSystemSoundsPlayback(allowHapticsAndSystemSoundsPlayback), allowBackgroundAudioPlayback(allowBackgroundAudioPlayback) {} public: friend bool operator==(const CameraSessionConfiguration& lhs, const CameraSessionConfiguration& rhs) = default; @@ -59,11 +60,13 @@ namespace margelo::nitro { static inline margelo::nitro::camera::CameraSessionConfiguration fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { jsi::Object obj = arg.asObject(runtime); return margelo::nitro::camera::CameraSessionConfiguration( + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "allowHapticsAndSystemSoundsPlayback"))), JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "allowBackgroundAudioPlayback"))) ); } static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::camera::CameraSessionConfiguration& arg) { jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "allowHapticsAndSystemSoundsPlayback"), JSIConverter>::toJSI(runtime, arg.allowHapticsAndSystemSoundsPlayback)); obj.setProperty(runtime, PropNameIDCache::get(runtime, "allowBackgroundAudioPlayback"), JSIConverter>::toJSI(runtime, arg.allowBackgroundAudioPlayback)); return obj; } @@ -75,6 +78,7 @@ namespace margelo::nitro { if (!nitro::isPlainObject(runtime, obj)) { return false; } + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "allowHapticsAndSystemSoundsPlayback")))) return false; if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "allowBackgroundAudioPlayback")))) return false; return true; } From dfeeb0bb828b0be918e7c9101da4485847ae9abd Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 29 Jul 2026 15:06:42 +0200 Subject: [PATCH 3/5] feat: Implement `allowHapticsAndSystemSoundsPlayback` on iOS --- .../ios/Hybrid Objects/HybridCameraSession.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift b/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift index 01112938b7..43c709eab0 100644 --- a/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift +++ b/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift @@ -90,6 +90,13 @@ final class HybridCameraSession: HybridCameraSessionSpec { } self.session.automaticallyConfiguresCaptureDeviceForWideColor = !hasCustomDynamicRangeConstraint + // Haptics and System Sounds Playback + if let allowHapticsAndSystemSoundsPlayback = config?.allowHapticsAndSystemSoundsPlayback { + let audioSession = AVAudioSession.sharedInstance() + if audioSession.allowHapticsAndSystemSoundsDuringRecording != allowHapticsAndSystemSoundsPlayback { + audioSession.setAllowHapticsAndSystemSoundsDuringRecording(allowHapticsAndSystemSoundsPlayback) + } + } // Background Audio Playback if #available(iOS 18.0, *) { if let allowBackgroundAudioPlayback = config?.allowBackgroundAudioPlayback { From 21301a6440367cff02778acef224a262bfff0635 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 29 Jul 2026 15:12:26 +0200 Subject: [PATCH 4/5] fix: `try` --- .../ios/Hybrid Objects/HybridCameraSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift b/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift index 43c709eab0..664d1c8d36 100644 --- a/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift +++ b/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraSession.swift @@ -94,7 +94,7 @@ final class HybridCameraSession: HybridCameraSessionSpec { if let allowHapticsAndSystemSoundsPlayback = config?.allowHapticsAndSystemSoundsPlayback { let audioSession = AVAudioSession.sharedInstance() if audioSession.allowHapticsAndSystemSoundsDuringRecording != allowHapticsAndSystemSoundsPlayback { - audioSession.setAllowHapticsAndSystemSoundsDuringRecording(allowHapticsAndSystemSoundsPlayback) + try audioSession.setAllowHapticsAndSystemSoundsDuringRecording(allowHapticsAndSystemSoundsPlayback) } } // Background Audio Playback From f11c2efd5eb84c80310c9772e43458d1fce5efc0 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 29 Jul 2026 15:12:35 +0200 Subject: [PATCH 5/5] chore: Add Harness tests for both audio configuration props --- .../__tests__/visioncamera.session.harness.ts | 116 +++++++++++++++++- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/apps/simple-camera/__tests__/visioncamera.session.harness.ts b/apps/simple-camera/__tests__/visioncamera.session.harness.ts index 884ea7a63a..f942cf95d1 100644 --- a/apps/simple-camera/__tests__/visioncamera.session.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.session.harness.ts @@ -1,9 +1,5 @@ -import { - beforeAll, - describe, - expect, - it, -} from 'react-native-harness' +import { Platform } from 'react-native' +import { beforeAll, describe, expect, it } from 'react-native-harness' import type { CameraDeviceFactory, TargetCameraPosition, @@ -231,6 +227,114 @@ describe('VisionCamera - Session', () => { await session.stop() }) + it('starts and reconfigures a session with haptics and system sounds playback enabled', async (context) => { + if (Platform.OS !== 'ios') { + return context.skip('allowHapticsAndSystemSoundsPlayback: iOS only') + } + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const connection = { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' as const }], + constraints: [], + } + + const started = deferred() + const stopped = deferred() + const startSub = session.addOnStartedListener(started.resolve) + const stopSub = session.addOnStoppedListener(stopped.resolve) + const errorSub = session.addOnErrorListener((error) => { + started.reject(error) + stopped.reject(error) + }) + + try { + const controllers = await session.configure([connection], { + allowHapticsAndSystemSoundsPlayback: true, + }) + expect(controllers).toHaveLength(1) + + await session.start() + await withTimeout(started.promise, 10_000, 'session start') + + const reconfiguredControllers = await session.configure([connection], { + allowHapticsAndSystemSoundsPlayback: false, + }) + expect(reconfiguredControllers).toHaveLength(1) + + await session.stop() + await withTimeout(stopped.promise, 10_000, 'session stop') + } finally { + await session.stop() + startSub.remove() + stopSub.remove() + errorSub.remove() + } + }) + + it('starts and reconfigures a session with background audio playback enabled', async (context) => { + if (Platform.OS !== 'ios') { + return context.skip('allowBackgroundAudioPlayback: iOS only') + } + const device = factory.getDefaultCamera('back') + expect(device).toBeDefined() + if (device == null) throw new Error('no back camera') + + const session = await VisionCamera.createCameraSession(false) + const photoOutput = VisionCamera.createPhotoOutput({ + targetResolution: CommonResolutions.HD_4_3, + containerFormat: 'jpeg', + quality: 0.8, + qualityPrioritization: 'balanced', + }) + const connection = { + input: device, + outputs: [{ output: photoOutput, mirrorMode: 'auto' as const }], + constraints: [], + } + + const started = deferred() + const stopped = deferred() + const startSub = session.addOnStartedListener(started.resolve) + const stopSub = session.addOnStoppedListener(stopped.resolve) + const errorSub = session.addOnErrorListener((error) => { + started.reject(error) + stopped.reject(error) + }) + + try { + const controllers = await session.configure([connection], { + allowBackgroundAudioPlayback: true, + }) + expect(controllers).toHaveLength(1) + + await session.start() + await withTimeout(started.promise, 10_000, 'session start') + + const reconfiguredControllers = await session.configure([connection], { + allowBackgroundAudioPlayback: false, + }) + expect(reconfiguredControllers).toHaveLength(1) + + await session.stop() + await withTimeout(stopped.promise, 10_000, 'session stop') + } finally { + await session.stop() + startSub.remove() + stopSub.remove() + errorSub.remove() + } + }) + it('reconfigures a running session with a new output set', async () => { const device = factory.getDefaultCamera('back') expect(device).toBeDefined()