capacitor-camera-view 2.4.0 → 3.0.1

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 (44) hide show
  1. package/CapacitorCameraView.podspec +1 -1
  2. package/Package.swift +1 -1
  3. package/README.md +341 -49
  4. package/android/build.gradle +0 -1
  5. package/android/src/main/AndroidManifest.xml +0 -1
  6. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
  7. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +945 -207
  8. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
  9. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
  10. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
  11. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
  12. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
  13. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
  14. package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
  15. package/dist/docs.json +475 -30
  16. package/dist/esm/definitions.d.ts +478 -26
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/dist/esm/utils.d.ts +59 -15
  19. package/dist/esm/utils.js +79 -38
  20. package/dist/esm/utils.js.map +1 -1
  21. package/dist/esm/web.d.ts +191 -13
  22. package/dist/esm/web.js +624 -141
  23. package/dist/esm/web.js.map +1 -1
  24. package/dist/plugin.cjs.js +711 -179
  25. package/dist/plugin.cjs.js.map +1 -1
  26. package/dist/plugin.js +711 -179
  27. package/dist/plugin.js.map +1 -1
  28. package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
  29. package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
  30. package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
  31. package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
  32. package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
  33. package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
  34. package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
  35. package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
  36. package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
  37. package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +200 -0
  38. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
  39. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
  40. package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
  41. package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
  42. package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +98 -65
  43. package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
  44. package/package.json +25 -9
@@ -1,16 +1,19 @@
1
1
  import AVFoundation
2
2
  import Foundation
3
- import UIKit
4
3
 
5
4
  extension CameraViewManager: AVCapturePhotoCaptureDelegate {
6
5
  /// Set up output for the capture session in case it's not configured yet
7
6
  /// Make sure to call `captureSession.beginConfiguration` before calling this
8
7
  ///
8
+ /// - Parameter prioritizeQuality: When `true`, the iOS 17+ responsive-capture
9
+ /// optimizations are skipped so captures always prioritize quality.
9
10
  /// - Throws: An error if the output cannot be set.
10
- internal func setupPhotoOutput() throws {
11
+ internal func setupPhotoOutput(prioritizeQuality: Bool = false) throws {
11
12
  if (captureSession.outputs.contains { $0 is AVCapturePhotoOutput }) {
12
- // Nothing todo, we already have an output and since we only
13
- // use outputs for taking photos here we don't need a new one
13
+ // The output already exists (session restart) - still reconfigure
14
+ // the responsiveness pipeline, since prioritizeQuality may have
15
+ // changed between sessions.
16
+ configureResponsiveCapture(prioritizeQuality: prioritizeQuality)
14
17
  return
15
18
  }
16
19
 
@@ -22,12 +25,67 @@ extension CameraViewManager: AVCapturePhotoCaptureDelegate {
22
25
  }
23
26
 
24
27
  captureSession.addOutput(avPhotoOutput)
28
+ deferStart(of: avPhotoOutput)
29
+
30
+ configureResponsiveCapture(prioritizeQuality: prioritizeQuality)
25
31
  }
26
32
 
27
- /// Delegate method called when a photo has been captured via `AVCapturePhotoCaptureDelegate`
33
+ /// Applies the iOS 17+ capture-responsiveness state on the photo output where
34
+ /// supported, reducing shot-to-shot latency.
28
35
  ///
29
- /// This method handles both the legacy UIImage-based callback and the optimized Data-based
30
- /// callback to eliminate double JPEG encoding when possible.
36
+ /// Ordering matters: responsive capture requires zero-shutter-lag, and fast
37
+ /// capture prioritization requires responsive capture, so capabilities are
38
+ /// enabled prerequisite-first and disabled dependent-first.
39
+ ///
40
+ /// - Parameter prioritizeQuality: When `true`, the optimizations are turned
41
+ /// off (not merely skipped) so a session restart that flips the option
42
+ /// always prioritizes quality.
43
+ private func configureResponsiveCapture(prioritizeQuality: Bool) {
44
+ if #available(iOS 17.0, *) {
45
+ let enable = !prioritizeQuality
46
+
47
+ if enable {
48
+ if avPhotoOutput.isZeroShutterLagSupported {
49
+ avPhotoOutput.isZeroShutterLagEnabled = true
50
+ }
51
+
52
+ if avPhotoOutput.isResponsiveCaptureSupported {
53
+ avPhotoOutput.isResponsiveCaptureEnabled = true
54
+
55
+ // Fast capture prioritization requires responsive capture
56
+ // to be enabled first.
57
+ if avPhotoOutput.isFastCapturePrioritizationSupported {
58
+ avPhotoOutput.isFastCapturePrioritizationEnabled = true
59
+ }
60
+ }
61
+ } else {
62
+ // Disable dependents before their prerequisites.
63
+ if avPhotoOutput.isFastCapturePrioritizationSupported {
64
+ avPhotoOutput.isFastCapturePrioritizationEnabled = false
65
+ }
66
+ if avPhotoOutput.isResponsiveCaptureSupported {
67
+ avPhotoOutput.isResponsiveCaptureEnabled = false
68
+ }
69
+ if avPhotoOutput.isZeroShutterLagSupported {
70
+ avPhotoOutput.isZeroShutterLagEnabled = false
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ /// Atomically consumes the photo completion handler so a photo-output
77
+ /// delegate callback can never race handler assignment on the capture thread.
78
+ internal func consumePhotoDataHandler() -> ((Data?, Error?) -> Void)? {
79
+ captureHandlerLock.lock()
80
+ defer { captureHandlerLock.unlock() }
81
+
82
+ let dataHandler = photoDataCaptureHandler
83
+ photoDataCaptureHandler = nil
84
+ return dataHandler
85
+ }
86
+
87
+ /// Delegate method called when a photo has been captured via `AVCapturePhotoCaptureDelegate`.
88
+ /// Returns the camera's native JPEG data directly, avoiding a double JPEG encode.
31
89
  ///
32
90
  /// - Parameters:
33
91
  /// - output: The photo output that captured the photo.
@@ -38,45 +96,19 @@ extension CameraViewManager: AVCapturePhotoCaptureDelegate {
38
96
  didFinishProcessingPhoto photo: AVCapturePhoto,
39
97
  error: Error?
40
98
  ) {
41
- // Handle optimized Data-based callback first (avoids double encoding)
42
- if let dataHandler = photoDataCaptureHandler {
43
- photoDataCaptureHandler = nil
99
+ guard let dataHandler = consumePhotoDataHandler() else { return }
44
100
 
45
- if let error = error {
46
- dataHandler(nil, error)
47
- return
48
- }
49
-
50
- guard let data = photo.fileDataRepresentation() else {
51
- dataHandler(nil, CameraError.photoOutputError)
52
- return
53
- }
54
-
55
- dataHandler(data, nil)
101
+ if let error = error {
102
+ dataHandler(nil, error)
56
103
  return
57
104
  }
58
105
 
59
- // Handle legacy UIImage-based callback
60
- if let imageHandler = photoCaptureHandler {
61
- photoCaptureHandler = nil
62
-
63
- if let error = error {
64
- imageHandler(nil, error)
65
- return
66
- }
67
-
68
- guard let data = photo.fileDataRepresentation() else {
69
- imageHandler(nil, CameraError.photoOutputError)
70
- return
71
- }
72
-
73
- guard let image = UIImage(data: data) else {
74
- imageHandler(nil, CameraError.photoOutputError)
75
- return
76
- }
77
-
78
- imageHandler(image, nil)
106
+ guard let data = photo.fileDataRepresentation() else {
107
+ dataHandler(nil, CameraError.photoOutputError)
108
+ return
79
109
  }
110
+
111
+ dataHandler(data, nil)
80
112
  }
81
113
 
82
114
  }
@@ -0,0 +1,57 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ /// Resolution / aspect-ratio selection for preview and photo capture.
5
+ ///
6
+ /// The session preset drives the active format and therefore the aspect ratio
7
+ /// of both the preview stream and captured photos, keeping what the user sees
8
+ /// consistent with what gets captured.
9
+ extension CameraViewManager {
10
+
11
+ /// Applies the session preset matching the configured aspect ratio.
12
+ ///
13
+ /// "16:9" prefers the HD video presets (highest first); "4:3" and `nil` use
14
+ /// the `.photo` preset with the sensor's native 4:3 photo format.
15
+ ///
16
+ /// Must run inside a configuration transaction on the session queue.
17
+ ///
18
+ /// - Parameter aspectRatio: The configured aspect ratio ("4:3", "16:9" or `nil`).
19
+ internal func applySessionPreset(forAspectRatio aspectRatio: String?) {
20
+ let preferredPresets: [AVCaptureSession.Preset] =
21
+ aspectRatio == "16:9"
22
+ ? [.hd4K3840x2160, .hd1920x1080, .hd1280x720, .photo]
23
+ : [.photo]
24
+
25
+ for preset in preferredPresets where captureSession.canSetSessionPreset(preset) {
26
+ captureSession.sessionPreset = preset
27
+ return
28
+ }
29
+ }
30
+
31
+ /// Re-applies the configured capture-resolution hint to the photo output.
32
+ ///
33
+ /// Picks the largest supported photo dimensions whose longer edge does not
34
+ /// exceed the hint, falling back to the smallest supported dimensions.
35
+ ///
36
+ /// `maxPhotoDimensions` is only valid against the *current* active format's
37
+ /// `supportedMaxPhotoDimensions` and is reset by AVFoundation whenever the
38
+ /// active format changes, so it must be re-applied on the session queue
39
+ /// after every committed transaction that changes the format.
40
+ internal func applyConfiguredMaxPhotoDimensions() {
41
+ guard let maxDimension = configuredCaptureMaxDimension,
42
+ let device = currentCameraDevice else { return }
43
+
44
+ let supported = device.activeFormat.supportedMaxPhotoDimensions
45
+
46
+ let byArea: (CMVideoDimensions, CMVideoDimensions) -> Bool = {
47
+ Int64($0.width) * Int64($0.height) < Int64($1.width) * Int64($1.height)
48
+ }
49
+ let fitting = supported.filter { Int(max($0.width, $0.height)) <= maxDimension }
50
+
51
+ guard let chosen = fitting.max(by: byArea) ?? supported.min(by: byArea) else {
52
+ return
53
+ }
54
+
55
+ avPhotoOutput.maxPhotoDimensions = chosen
56
+ }
57
+ }
@@ -0,0 +1,200 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import UIKit
4
+
5
+ /// Rotation handling for the camera preview and capture outputs.
6
+ ///
7
+ /// On iOS 17+ this adopts `AVCaptureDevice.RotationCoordinator`, which reports
8
+ /// horizon-level rotation angles for the preview and for capture and updates
9
+ /// them continuously without relying on `UIDevice` orientation notifications.
10
+ /// On iOS 16 it falls back to the legacy `videoOrientation` API driven by device
11
+ /// orientation changes (see `setupOrientationObserver`).
12
+ extension CameraViewManager {
13
+
14
+ // MARK: - iOS 17+ Rotation Coordinator
15
+
16
+ /// Typed accessor over the `Any?`-backed coordinator storage.
17
+ ///
18
+ /// Takes `rotationCoordinatorLock`: the storage is written on the main queue
19
+ /// but read from the Capacitor call thread and the session queue, and an
20
+ /// unsynchronized ARC reassignment racing a read is undefined behavior.
21
+ @available(iOS 17.0, *)
22
+ internal var rotationCoordinator: AVCaptureDevice.RotationCoordinator? {
23
+ get {
24
+ rotationCoordinatorLock.lock()
25
+ defer { rotationCoordinatorLock.unlock() }
26
+ return rotationCoordinatorStorage as? AVCaptureDevice.RotationCoordinator
27
+ }
28
+ set {
29
+ rotationCoordinatorLock.lock()
30
+ defer { rotationCoordinatorLock.unlock() }
31
+ rotationCoordinatorStorage = newValue
32
+ }
33
+ }
34
+
35
+ /// (Re)configures rotation handling for the current camera device. Safe to
36
+ /// call whenever the active device changes (flip, triple-camera upgrade) or
37
+ /// once the preview becomes available.
38
+ internal func configureRotationHandling() {
39
+ if #available(iOS 17.0, *) {
40
+ setupRotationCoordinator()
41
+ } else {
42
+ DispatchQueue.main.async { [weak self] in
43
+ self?.updateLegacyPreviewOrientation()
44
+ }
45
+ }
46
+ }
47
+
48
+ /// Creates a rotation coordinator for the active device and observes its
49
+ /// preview angle to keep the preview level with the horizon. Recreated on
50
+ /// every device change so the angles track the physical camera in use.
51
+ @available(iOS 17.0, *)
52
+ private func setupRotationCoordinator() {
53
+ DispatchQueue.main.async { [weak self] in
54
+ guard let self = self,
55
+ let device = self.currentCameraDevice,
56
+ self.videoPreviewLayer.session != nil else { return }
57
+
58
+ // Invalidate the previous observation before replacing the coordinator.
59
+ self.rotationObservation?.invalidate()
60
+
61
+ let coordinator = AVCaptureDevice.RotationCoordinator(
62
+ device: device,
63
+ previewLayer: self.videoPreviewLayer
64
+ )
65
+ self.rotationCoordinator = coordinator
66
+
67
+ // `.initial` applies the current angle immediately; subsequent
68
+ // changes keep the preview rotating continuously.
69
+ self.rotationObservation = coordinator.observe(
70
+ \.videoRotationAngleForHorizonLevelPreview,
71
+ options: [.initial, .new]
72
+ ) { [weak self] coordinator, _ in
73
+ self?.applyPreviewRotationAngle(
74
+ coordinator.videoRotationAngleForHorizonLevelPreview
75
+ )
76
+ }
77
+ }
78
+ }
79
+
80
+ /// Applies a preview rotation angle to the preview layer's connection.
81
+ @available(iOS 17.0, *)
82
+ private func applyPreviewRotationAngle(_ angle: CGFloat) {
83
+ DispatchQueue.main.async { [weak self] in
84
+ guard let self = self,
85
+ let connection = self.videoPreviewLayer.connection,
86
+ connection.isVideoRotationAngleSupported(angle) else { return }
87
+
88
+ connection.videoRotationAngle = angle
89
+
90
+ // Keep the preview layer filling the (possibly rotated) bounds.
91
+ if let view = self.webView {
92
+ self.videoPreviewLayer.frame = view.bounds
93
+ }
94
+ }
95
+ }
96
+
97
+ // MARK: - Capture Orientation
98
+
99
+ /// Applies the correct rotation to a capture output connection (photo,
100
+ /// sample, or movie) so captured media matches what the preview shows.
101
+ internal func applyCaptureOrientation(to connection: AVCaptureConnection) {
102
+ if #available(iOS 17.0, *) {
103
+ // A nil coordinator only happens for a capture racing session
104
+ // teardown; leaving the connection at its default angle is harmless.
105
+ guard let coordinator = rotationCoordinator else { return }
106
+
107
+ // A detached preview layer makes the coordinator report a preview angle of 0.
108
+ let angle = videoPreviewLayer.superlayer == nil
109
+ ? coordinator.videoRotationAngleForHorizonLevelCapture
110
+ : coordinator.videoRotationAngleForHorizonLevelPreview
111
+
112
+ if connection.isVideoRotationAngleSupported(angle) {
113
+ connection.videoRotationAngle = angle
114
+ }
115
+ } else {
116
+ applyLegacyCaptureOrientation(to: connection)
117
+ }
118
+ }
119
+
120
+ // MARK: - iOS 16 Legacy Path
121
+
122
+ /// Sets up device orientation handling (called from `init`).
123
+ ///
124
+ /// No-op on iOS 17+, where the rotation coordinator takes over. On iOS 16
125
+ /// the plugin generates the device orientation notifications itself rather
126
+ /// than relying on the host app having started them.
127
+ internal func setupOrientationObserver() {
128
+ if #available(iOS 17.0, *) {
129
+ return
130
+ }
131
+
132
+ UIDevice.current.beginGeneratingDeviceOrientationNotifications()
133
+ orientationObserverToken = NotificationCenter.default.addObserver(
134
+ forName: UIDevice.orientationDidChangeNotification,
135
+ object: nil,
136
+ queue: .main
137
+ ) { [weak self] _ in
138
+ self?.updateLegacyPreviewOrientation()
139
+ }
140
+ }
141
+
142
+ /// Tears down the block-based orientation observer registered by
143
+ /// `setupOrientationObserver`. `NotificationCenter.removeObserver(self)`
144
+ /// does not remove block-based observers, so the token must be removed
145
+ /// explicitly or it leaks for the process lifetime.
146
+ internal func removeOrientationObserver() {
147
+ if let token = orientationObserverToken {
148
+ NotificationCenter.default.removeObserver(token)
149
+ orientationObserverToken = nil
150
+ }
151
+
152
+ if #unavailable(iOS 17.0) {
153
+ UIDevice.current.endGeneratingDeviceOrientationNotifications()
154
+ }
155
+ }
156
+
157
+ /// Legacy capture orientation: mirror the preview connection's
158
+ /// `videoOrientation` onto the capture connection.
159
+ @available(iOS, introduced: 16.0, deprecated: 17.0,
160
+ message: "Uses videoOrientation; replaced by RotationCoordinator on iOS 17+")
161
+ private func applyLegacyCaptureOrientation(to connection: AVCaptureConnection) {
162
+ guard let previewConnection = videoPreviewLayer.connection,
163
+ connection.isVideoOrientationSupported else { return }
164
+ connection.videoOrientation = previewConnection.videoOrientation
165
+ }
166
+
167
+ /// Legacy preview orientation: derive `videoOrientation` from the current
168
+ /// interface orientation. Driven by `UIDevice` orientation notifications.
169
+ @available(iOS, introduced: 16.0, deprecated: 17.0,
170
+ message: "Uses videoOrientation; replaced by RotationCoordinator on iOS 17+")
171
+ internal func updateLegacyPreviewOrientation() {
172
+ guard let connection = videoPreviewLayer.connection,
173
+ connection.isVideoOrientationSupported else { return }
174
+
175
+ let interfaceOrientation = UIApplication.shared.connectedScenes
176
+ .compactMap { $0 as? UIWindowScene }
177
+ .first?.interfaceOrientation ?? .portrait
178
+
179
+ let videoOrientation: AVCaptureVideoOrientation
180
+ switch interfaceOrientation {
181
+ case .portrait:
182
+ videoOrientation = .portrait
183
+ case .landscapeLeft:
184
+ videoOrientation = .landscapeLeft
185
+ case .landscapeRight:
186
+ videoOrientation = .landscapeRight
187
+ case .portraitUpsideDown:
188
+ videoOrientation = .portraitUpsideDown
189
+ default:
190
+ videoOrientation = .portrait
191
+ }
192
+
193
+ connection.videoOrientation = videoOrientation
194
+
195
+ // Update the frame of the preview layer to match the new bounds.
196
+ if let view = webView {
197
+ videoPreviewLayer.frame = view.bounds
198
+ }
199
+ }
200
+ }
@@ -31,6 +31,21 @@ extension CameraViewManager: AVCaptureVideoDataOutputSampleBufferDelegate {
31
31
  }
32
32
 
33
33
  captureSession.addOutput(avVideoDataOutput)
34
+ deferStart(of: avVideoDataOutput)
35
+ }
36
+
37
+ /// Atomically consumes the snapshot completion handler and cancels its
38
+ /// timeout. Returns the handler to the caller that wins the race (frame
39
+ /// delivery or timeout) and `nil` to any later caller.
40
+ internal func consumeSnapshotHandler() -> ((UIImage?, Error?) -> Void)? {
41
+ captureHandlerLock.lock()
42
+ defer { captureHandlerLock.unlock() }
43
+
44
+ guard let handler = snapshotCompletionHandler else { return nil }
45
+ snapshotCompletionHandler = nil
46
+ snapshotTimeoutWorkItem?.cancel()
47
+ snapshotTimeoutWorkItem = nil
48
+ return handler
34
49
  }
35
50
 
36
51
  /// Capture a snapshot from the camera feed using the shared Metal-backed CIContext.
@@ -39,12 +54,12 @@ extension CameraViewManager: AVCaptureVideoDataOutputSampleBufferDelegate {
39
54
  _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
40
55
  from connection: AVCaptureConnection
41
56
  ) {
42
- // Only process if we have a completion handler set
43
- guard let completionHandler = snapshotCompletionHandler else { return }
44
-
45
- // Clear the completion handler to ensure we only capture one frame
46
- snapshotCompletionHandler = nil
57
+ // Atomically claim the snapshot; returns nil if it was already consumed
58
+ // (e.g. by the timeout) or if no capture is in flight. This also cancels
59
+ // the timeout so the promise settles exactly once.
60
+ guard let completionHandler = consumeSnapshotHandler() else { return }
47
61
 
62
+ // Stop delivery to ensure we only capture one frame.
48
63
  avVideoDataOutput.setSampleBufferDelegate(nil, queue: nil)
49
64
 
50
65
  guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
@@ -68,12 +83,9 @@ extension CameraViewManager: AVCaptureVideoDataOutputSampleBufferDelegate {
68
83
  _ output: AVCaptureOutput, didDrop sampleBuffer: CMSampleBuffer,
69
84
  from connection: AVCaptureConnection
70
85
  ) {
71
- // If we have a completion handler and a frame was dropped, report the error
72
- if let completionHandler = snapshotCompletionHandler {
73
- snapshotCompletionHandler = nil
74
- avVideoDataOutput.setSampleBufferDelegate(nil, queue: nil)
75
-
76
- completionHandler(nil, CameraError.frameCaptureError)
77
- }
86
+ // With `alwaysDiscardsLateVideoFrames` enabled a transient dropped frame
87
+ // is normal and must not fail the capture. Keep the delegate installed
88
+ // and wait for the next delivered frame; the timeout guards against the
89
+ // case where no frame ever arrives.
78
90
  }
79
91
  }
@@ -29,14 +29,7 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
29
29
  guard let self = self else { return }
30
30
 
31
31
  guard self.captureSession.isRunning else {
32
- // Session may be temporarily stopped (e.g. iOS stops the capture session
33
- // when reconfiguring audio after a microphone permission grant). Wait for
34
- // it to resume and retry rather than failing immediately.
35
- self.waitForSessionThenStartRecording(
36
- enableAudio: enableAudio,
37
- videoQuality: videoQuality,
38
- completion: completion
39
- )
32
+ DispatchQueue.main.async { completion(CameraError.sessionNotRunning) }
40
33
  return
41
34
  }
42
35
 
@@ -64,6 +57,11 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
64
57
  return
65
58
  }
66
59
  self.captureSession.addOutput(self.avMovieOutput)
60
+
61
+ // Never deferred: this output is added because a recording is
62
+ // starting right now, and file outputs default to deferred for
63
+ // host apps linked against the iOS 26 SDK.
64
+ self.deferStart(of: self.avMovieOutput, false)
67
65
  }
68
66
 
69
67
  // Add audio input if requested
@@ -83,11 +81,8 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
83
81
  self.captureSession.commitConfiguration()
84
82
 
85
83
  // Set orientation on the movie output connection
86
- if let connection = self.avMovieOutput.connection(with: .video),
87
- let previewConnection = self.videoPreviewLayer.connection {
88
- if connection.isVideoOrientationSupported {
89
- connection.videoOrientation = previewConnection.videoOrientation
90
- }
84
+ if let connection = self.avMovieOutput.connection(with: .video) {
85
+ self.applyCaptureOrientation(to: connection)
91
86
  if connection.isVideoMirroringSupported {
92
87
  connection.isVideoMirrored = self.currentCameraDevice?.position == .front
93
88
  }
@@ -156,6 +151,11 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
156
151
  self.restoreSessionPreset()
157
152
  self.recordingWithAudio = false
158
153
  self.captureSession.commitConfiguration()
154
+
155
+ // Restoring the pre-recording preset may change the active format,
156
+ // which resets the photo output's maxPhotoDimensions; re-apply the
157
+ // session's capture-resolution hint.
158
+ self.applyConfiguredMaxPhotoDimensions()
159
159
  }
160
160
 
161
161
  if let error = error {
@@ -172,65 +172,6 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
172
172
 
173
173
  // MARK: - Private Helpers
174
174
 
175
- /// Waits for the capture session to start running, then retries `startRecording`.
176
- ///
177
- /// Called when `startRecording` finds the session temporarily stopped (e.g. after
178
- /// iOS reconfigures audio following a first-time microphone permission grant).
179
- /// Both the notification path and the timeout path serialize through `sessionQueue`,
180
- /// so `handled` is accessed on a single serial queue and needs no additional lock.
181
- private func waitForSessionThenStartRecording(
182
- enableAudio: Bool,
183
- videoQuality: VideoRecordingQuality,
184
- completion: @escaping (Error?) -> Void
185
- ) {
186
- let sessionQueue = self.sessionQueue
187
- // Keep the token so we can remove the observer on success and timeout paths.
188
- var observerToken: NSObjectProtocol?
189
- var handled = false
190
-
191
- observerToken = NotificationCenter.default.addObserver(
192
- forName: .AVCaptureSessionDidStartRunning,
193
- object: captureSession,
194
- queue: nil
195
- ) { [weak self] _ in
196
- sessionQueue.async {
197
- guard !handled else { return }
198
- handled = true
199
- if let token = observerToken {
200
- NotificationCenter.default.removeObserver(token)
201
- observerToken = nil
202
- }
203
- guard let self = self else { return }
204
- self.startRecording(
205
- enableAudio: enableAudio,
206
- videoQuality: videoQuality,
207
- completion: completion
208
- )
209
- }
210
- }
211
-
212
- // Timeout: if the session hasn't restarted within 2 seconds, give up.
213
- sessionQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in
214
- guard !handled else { return }
215
- handled = true
216
- if let token = observerToken {
217
- NotificationCenter.default.removeObserver(token)
218
- observerToken = nil
219
- }
220
- guard let self = self else { return }
221
- // One final check in case the session started just as we timed out.
222
- if self.captureSession.isRunning {
223
- self.startRecording(
224
- enableAudio: enableAudio,
225
- videoQuality: videoQuality,
226
- completion: completion
227
- )
228
- } else {
229
- DispatchQueue.main.async { completion(CameraError.sessionNotRunning) }
230
- }
231
- }
232
- }
233
-
234
175
  /// Resolves the appropriate AVCaptureSession.Preset for the given VideoRecordingQuality,
235
176
  private func resolveRecordingPreset(for videoQuality: VideoRecordingQuality) -> AVCaptureSession.Preset {
236
177
  let preferredPresets: [AVCaptureSession.Preset]
@@ -276,7 +217,15 @@ extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
276
217
  (input as? AVCaptureDeviceInput)?.device.hasMediaType(.audio) == true
277
218
  }
278
219
 
279
- guard !hasAudioInput else { return }
220
+ guard !hasAudioInput else {
221
+ // An audio input is already attached (e.g. a leftover from a
222
+ // previous recording). Recording will still include audio, so
223
+ // `recordingWithAudio` must reflect that or the completion
224
+ // delegate won't remove the input afterwards, leaving it
225
+ // stray-attached to the session.
226
+ recordingWithAudio = true
227
+ return
228
+ }
280
229
 
281
230
  guard let microphone = AVCaptureDevice.default(for: .audio) else {
282
231
  throw CameraError.audioDeviceUnavailable