camera: iOS implementation (Phase 2+3)
Native plugin owning AVCaptureSession + AVAssetWriter, mirroring
telegram-iOS's Camera module decomposition. Photo + video capture with
the writer-track transform set from a per-call orientation snapshot
(the three-way preview/capture/device split that camera_avfoundation
can't give us).
Modules:
CameraPlugin channels + per-handle instance map
CameraInstance session + texture + outputs + recorder
CameraSession AVCaptureSession + runtime-error/interrupt obs
CaptureDevice front/back discovery, per-device config
PhotoOutput AVCapturePhotoOutput, per-shot orientation
VideoRecorder AVAssetWriter, lazy inputs, pending-audio queue,
stop()/cancel() pair (matches telegram)
PreviewSink CVPixelBuffer → FlutterTexture
AudioSession setCategory + setActive(true) (only-widen)
DeviceOrientationBridge
Recorder details:
- Lazy videoInput/audioInput on first sample, sourceFormatHint:.
- Audio settings derived from CMAudioFormatDescriptionGet*
+ recommendedAudioSettingsForAssetWriter, gated startWriting.
- Stop sets stopSampleTime; next sample crossing it triggers
maybeFinish → finishWriting. No watchdog — telegram pattern.
- cancel() drops pending audio + cancelWriting + deletes file,
used by CameraInstance.dispose when teardown finds in-flight
recording.
- Diagnostic stream → ux/camera/events {event: "diagnostic"}.
Dart surface extensions over Phase 1:
- UxCameraValue.audioPermissionGranted
- UxCameraController.refreshAudioPermission()
- Static UxCameraController.audioPermissionGranted() /
openSystemSettings()
- UxCameraDiagnostic event variant
- FakeUxCameraBackend.{emitDiagnostic, audioPermission,
openSettingsCalls}
Tests: 32/32 in test/camera (controller + channel) green.
This commit is contained in:
71
ios/Classes/Camera/AudioSession.swift
Normal file
71
ios/Classes/Camera/AudioSession.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import AVFoundation
|
||||
|
||||
/// Idempotent helpers for upgrading the app's shared `AVAudioSession`
|
||||
/// to the union of categories needed for capture without trampling
|
||||
/// what other modules (audio player, video_player) had set. Pattern
|
||||
/// mirrors `camera_avfoundation`'s `upgradeAudioSessionCategory` —
|
||||
/// only ever WIDENS the category, never narrows.
|
||||
///
|
||||
/// We pair this with the session's
|
||||
/// `automaticallyConfiguresApplicationAudioSession = false` +
|
||||
/// `usesApplicationAudioSession = true` (set in [CameraSession.init])
|
||||
/// so AVCaptureSession doesn't yank the category back.
|
||||
enum AudioSession {
|
||||
/// Upgrade the shared category to include `.playAndRecord` (and
|
||||
/// the given options union'd with whatever's already set). No-op
|
||||
/// when the union equals the current state, so this is cheap to
|
||||
/// call on every recording start.
|
||||
static func upgradeForRecording() {
|
||||
upgrade(
|
||||
requestedCategory: .playAndRecord,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowAirPlay]
|
||||
)
|
||||
}
|
||||
|
||||
private static func upgrade(
|
||||
requestedCategory: AVAudioSession.Category,
|
||||
options: AVAudioSession.CategoryOptions
|
||||
) {
|
||||
let playCategories: Set<AVAudioSession.Category> = [.playback, .playAndRecord]
|
||||
let recordCategories: Set<AVAudioSession.Category> = [.record, .playAndRecord]
|
||||
let currentCategory = AVAudioSession.sharedInstance().category
|
||||
let requiredCategories: Set<AVAudioSession.Category> = [
|
||||
requestedCategory, currentCategory,
|
||||
]
|
||||
|
||||
let requiresPlay = !requiredCategories.isDisjoint(with: playCategories)
|
||||
let requiresRecord = !requiredCategories.isDisjoint(with: recordCategories)
|
||||
|
||||
var finalCategory = requestedCategory
|
||||
if requiresPlay && requiresRecord {
|
||||
finalCategory = .playAndRecord
|
||||
} else if requiresPlay {
|
||||
finalCategory = .playback
|
||||
} else if requiresRecord {
|
||||
finalCategory = .record
|
||||
}
|
||||
|
||||
let finalOptions = AVAudioSession.sharedInstance().categoryOptions
|
||||
.union(options)
|
||||
|
||||
if finalCategory == currentCategory
|
||||
&& finalOptions == AVAudioSession.sharedInstance().categoryOptions
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setCategory(
|
||||
finalCategory,
|
||||
options: finalOptions
|
||||
)
|
||||
// With AVCaptureSession.usesApplicationAudioSession = true and
|
||||
// automaticallyConfiguresApplicationAudioSession = false, the
|
||||
// app owns activation — without this, the mic input never
|
||||
// delivers sample buffers. Telegram does the same from
|
||||
// ManagedAudioSession.activate (setActive(true)).
|
||||
try? AVAudioSession.sharedInstance().setActive(
|
||||
true,
|
||||
options: [.notifyOthersOnDeactivation]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user