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:
98
ios/Classes/Camera/CaptureDevice.swift
Normal file
98
ios/Classes/Camera/CaptureDevice.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import AVFoundation
|
||||
|
||||
/// Static helpers for camera-device discovery and per-device init.
|
||||
///
|
||||
/// Mirrors what telegram-ios's `CameraDevice` does for the
|
||||
/// session-priority + format-negotiation cases we actually care
|
||||
/// about — front / back wide-angle only. Telegram's preference for
|
||||
/// `TripleCamera` / `DualCamera` is for multi-cam zoom UX we don't
|
||||
/// build today; if we ever need it, this is where it goes.
|
||||
enum CaptureDevice {
|
||||
/// Enumerate the front + back wide-angle cameras the system
|
||||
/// exposes. Order: back first, front second. Stable for the
|
||||
/// lifetime of the process — iOS doesn't hot-swap cameras.
|
||||
static func discover() -> [DiscoveredCamera] {
|
||||
let session = AVCaptureDevice.DiscoverySession(
|
||||
deviceTypes: [.builtInWideAngleCamera],
|
||||
mediaType: .video,
|
||||
position: .unspecified
|
||||
)
|
||||
// Sort so back devices come first; the chat composer opens
|
||||
// the back camera by default elsewhere, so this matches the
|
||||
// common "first available" pick.
|
||||
return session.devices
|
||||
.sorted { positionRank($0.position) < positionRank($1.position) }
|
||||
.map { device in
|
||||
DiscoveredCamera(
|
||||
device: device,
|
||||
lens: lensName(for: device.position),
|
||||
// iOS doesn't expose sensor orientation directly;
|
||||
// 90° matches what `camera_avfoundation` reports
|
||||
// and what banlu's `normalizeCameraCapture` math
|
||||
// assumes for iOS sensors.
|
||||
sensorOrientation: 90
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply our default per-device config: continuous autofocus,
|
||||
/// continuous auto-exposure, torch off. Idempotent; safe to call
|
||||
/// repeatedly. The block is wrapped in
|
||||
/// `lockForConfiguration` / `unlockForConfiguration`.
|
||||
static func applyDefaults(_ device: AVCaptureDevice) {
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
defer { device.unlockForConfiguration() }
|
||||
|
||||
if device.isFocusModeSupported(.continuousAutoFocus) {
|
||||
device.focusMode = .continuousAutoFocus
|
||||
}
|
||||
if device.isExposureModeSupported(.continuousAutoExposure) {
|
||||
device.exposureMode = .continuousAutoExposure
|
||||
}
|
||||
if device.hasTorch && device.isTorchModeSupported(.off) {
|
||||
device.torchMode = .off
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — a device that refuses lockForConfiguration
|
||||
// will still capture frames; we just can't tweak focus.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - private
|
||||
|
||||
private static func positionRank(_ position: AVCaptureDevice.Position) -> Int {
|
||||
switch position {
|
||||
case .back: return 0
|
||||
case .front: return 1
|
||||
default: return 2
|
||||
}
|
||||
}
|
||||
|
||||
private static func lensName(for position: AVCaptureDevice.Position) -> String {
|
||||
switch position {
|
||||
case .front: return "front"
|
||||
case .back: return "back"
|
||||
default: return "back"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the discovery result. `lens` and `sensorOrientation`
|
||||
/// match the wire shape expected by Dart's
|
||||
/// `MethodChannelUxCameraBackend.availableCameras()`.
|
||||
struct DiscoveredCamera {
|
||||
let device: AVCaptureDevice
|
||||
let lens: String
|
||||
let sensorOrientation: Int
|
||||
|
||||
var uniqueID: String { device.uniqueID }
|
||||
|
||||
func toWire() -> [String: Any] {
|
||||
return [
|
||||
"id": uniqueID,
|
||||
"lens": lens,
|
||||
"sensorOrientation": sensorOrientation,
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user