Two changes that target the macOS "camera not found" leak after a
few open/close cycles. macOS's `AVCaptureDevice.DiscoverySession`
excludes devices that are still claimed by another session — even
our own zombie session that hasn't fully released its grip on the
hardware. So if dispose leaves the session in a partially torn-down
state, the next `availableCameras` returns empty.
CameraInstance.dispose now:
- Cancels the recorder (was already there) so the audio
data output's retain on the recorder drops.
- Stops the session.
- **Nils sample-buffer delegates** on the video + audio data
outputs before removing them. `setSampleBufferDelegate` holds a
strong reference to the delegate; the macOS reference to our
`SampleFanout` was transitively keeping the session alive.
- Removes inputs + outputs inside a single
`session.configure { … }` block (begin/commitConfiguration) so
AVFoundation sees the teardown as one atomic transition rather
than a sequence of partial states. Apple's docs are explicit on
this; we weren't following.
- Clears the strong references to the instance vars.
Plugin diagnostics:
- When availableCameras returns empty, native now emits an event
`{handle: -1, event: "diagnostic", message: …}` carrying the
current `devicesInUse`, `audioInUse` and `instances` keys.
Per-handle diagnostics already flow through a controller's
`_onEvent`; plugin-level ones (handle == -1) had no path to the
log_server jsonl.
- `MethodChannelUxCameraBackend` now subscribes to its raw event
stream once and pipes any handle=-1 diagnostic through
`ux.Log.tag('camera').i('plugin: …')`. The subscription kicks in
when the broadcast stream is first accessed (still lazy —
matches the prior behavior).
If the macOS "camera not found" reproduces, the jsonl will show
which side leaked: a non-empty `devicesInUse` says our claim
tracking is stale; an empty one says AVFoundation itself is
holding the hardware.
`device.activeFormat.formatDescription` reports the device's
*selected* format, but on macOS the session-preset remap means the
data output sometimes delivers a different resolution than what
the active format claims. The mismatch surfaced as a stretched
preview on macOS: camera_thumb's SizedBox was sized for a 4:3
buffer, the FittedBox(cover) was given a 16:9 texture, so the
texture stretched to fill the wrongly-shaped box.
PreviewSink now snapshots `CVPixelBufferGet{Width,Height}` from
the first sample buffer that arrives and forwards it via a
callback. CameraInstance hooks the callback to emit the existing
`previewSizeChanged` event (same shape the Android backend uses).
The Dart controller writes it into `value.previewSize`,
camera_thumb's SizedBox snaps to the real buffer aspect, and
FittedBox(cover) crops cleanly without stretching.
Identical wire shape and event name as the Android equivalent —
no Dart changes needed.
macOS preview was stretching (aspect wrong) and macOS photo capture
was rotating the landscape sensor 90° because the shared
PhotoOutput / CameraInstance code was setting
`AVCaptureConnection.videoOrientation` from the orientation snapshot
unconditionally. iOS needs that to rotate sample buffers to portrait;
macOS desktop cams are physically landscape and any rotation just
skews the result.
Moved the rotation call behind a per-platform extension on
`AVCaptureConnection`:
- `ios/Classes/Camera/AVCaptureConnection+iOS.swift` applies the
snapshot orientation (current behavior).
- `macos/Classes/Camera/AVCaptureConnection+macOS.swift` is a
no-op. macOS-flavoured photos / preview frames now flow at
native landscape orientation.
`CaptureDevice` reports sensorOrientation=0 on macOS (was hardcoded
90 for iOS); on macOS the page's `normalizeCameraCapture` math then
collapses to identity and the saved JPEG stays the landscape the
sensor produced. iOS keeps sensorOrientation=90 (matches
camera_avfoundation's reported value and the existing capture-
transform math).
Photo and video paths now both produce upright content on macOS
(video already worked because VideoRecorder's transform table maps
the always-portraitUp macOS snapshot to `.identity`).
Discovery was hard-coded to `.builtInWideAngleCamera` only — that
catches the iOS front/back cameras and the macOS FaceTime HD on
macOS 14+, but missed USB webcams (`.external` on 14+,
`.externalUnknown` before) and iPhone-as-webcam Continuity Camera
(`.continuityCamera` on 14+). On Macs whose built-in camera doesn't
expose itself as `.builtInWideAngleCamera`, the result was "no
camera detected".
Device types are now platform-conditional: iOS keeps the wide-angle
filter as-is; macOS adds the externals + Continuity (gated via
`if #available(macOS 14.0, *)` for the post-14 forms vs the
deprecated `.externalUnknown` for older macOS). The `#if os(macOS)`
guard is unavoidable — `.external` and friends literally aren't
declared as enum cases on iOS.
Reuse the AVFoundation Swift files between iOS and macOS without
sprinkling `#if canImport(UIKit)` through them. The split is:
darwin/Camera/ platform-shared (AVFoundation only)
CameraPlugin channel + instance map
CameraInstance session + outputs + texture
CameraSession AVCaptureSession + runtime-error obs
CaptureDevice front/back discovery
PhotoOutput AVCapturePhotoOutput
PreviewSink CVPixelBuffer → FlutterTexture
VideoRecorder AVAssetWriter
DeviceOrientation wire-string enum
ios/Classes/Camera/ iOS-only impls + extensions
AudioSession AVAudioSession.upgradeForRecording
DeviceOrientationBridge UIDevice.orientation listener
CameraSession+iOS AVCaptureSessionWasInterrupted obs
+ InterruptionReason decode + the
application-audio-session flags
(all iOS-only on AVCaptureSession)
CameraSettings UIApplication.openSettingsURLString
FlutterRegistrar+iOS method-form of textures/messenger
macos/Classes/Camera/ macOS no-op stubs (same surface)
AudioSession no-op (no AVAudioSession on macOS)
DeviceOrientationBridge no-op (desktops don't rotate)
CameraSession+macOS no-op setupPlatform()
CameraSettings NSWorkspace → System Settings'
Privacy_Camera pane
FlutterRegistrar+macOS property-form of textures/messenger
`CameraSession.init` now calls `setupPlatform()` which each platform
provides via an extension — keeps the iOS-only interruption observer
and the `automaticallyConfiguresApplicationAudioSession` /
`usesApplicationAudioSession` flags (both iOS-only on AVCaptureSession)
out of the shared file. Flash-mode in PhotoOutput uses
`if #available(macOS 11/13, *)` rather than `#if`, since those are
plain version gates not platform splits.
The shared files compile into the iOS pod from `ios/Classes/Camera-shared/`
and into the macOS pod from `macos/Classes/Camera-shared/`, each a
mirror populated by a `prepare_command` in the podspec:
rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared
Symlinks and `../` source globs both fail — Pathname.glob bails on
symlinks, and CocoaPods silently drops paths that escape the pod
directory. The mirror destinations are .gitignore'd.
macOS UxPlugin now registers CameraPlugin alongside the others.