camera: macOS port via darwin/ split (no shared-file pragmas)

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.
This commit is contained in:
agra
2026-05-13 18:53:46 +03:00
parent 16f986ab37
commit 14565ebd7a
22 changed files with 282 additions and 106 deletions

View File

@@ -0,0 +1,81 @@
import AVFoundation
import Foundation
/// Thin wrapper around `AVCaptureSession` that owns the lifecycle
/// helpers and observes the runtime-error / interruption
/// notifications (iOS only macOS has no equivalent), surfacing them
/// through closures so [CameraInstance] doesn't repeat the boilerplate.
///
/// All `AVCaptureSession` mutations (input/output add/remove, start,
/// stop) must run on the caller's `sessionQueue`. This class doesn't
/// enforce that; the contract is that
/// [CameraInstance.sessionQueue.async { }] wraps every call site.
///
/// Platform-specific init bits the application-audio-session flags
/// and the interruption observers live in `setupPlatform()` whose
/// real implementation is in `ios/Classes/Camera/CameraSession+iOS.swift`.
/// The macOS counterpart (`macos/Classes/Camera/CameraSession+macOS.swift`)
/// is a no-op: macOS has no `AVAudioSession` and no
/// `AVCaptureSessionWasInterrupted` notification.
final class CameraSession {
let av: AVCaptureSession
/// Called on `.main` when the session reports an unrecoverable
/// runtime error. Notable case: `.mediaServicesWereReset` the
/// caller typically tears down and recreates the session.
var onRuntimeError: ((NSError) -> Void)?
/// Called on `.main` when the session is interrupted (e.g. video
/// device taken by another foreground client, audio session
/// interruption, or app backgrounded with `usesApplicationAudioSession`).
/// iOS only never fires on macOS.
var onInterrupted: ((String) -> Void)?
/// Called on `.main` when an earlier interruption ends. iOS only.
var onResumed: (() -> Void)?
var runtimeErrorObserver: NSObjectProtocol?
var interruptedObserver: NSObjectProtocol?
var resumedObserver: NSObjectProtocol?
init() {
av = AVCaptureSession()
runtimeErrorObserver = NotificationCenter.default.addObserver(
forName: .AVCaptureSessionRuntimeError,
object: av,
queue: .main
) { [weak self] note in
let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError
?? NSError(domain: "ux.camera", code: -1)
self?.onRuntimeError?(error)
}
setupPlatform()
}
deinit {
if let o = runtimeErrorObserver { NotificationCenter.default.removeObserver(o) }
if let o = interruptedObserver { NotificationCenter.default.removeObserver(o) }
if let o = resumedObserver { NotificationCenter.default.removeObserver(o) }
}
/// Configure block; pairs `beginConfiguration` /
/// `commitConfiguration` so every add/remove batch lands as one
/// session update. Caller must be on sessionQueue.
func configure(_ block: () -> Void) {
av.beginConfiguration()
block()
av.commitConfiguration()
}
/// Start the session if it isn't already running.
/// Caller must be on sessionQueue.
func start() {
if !av.isRunning { av.startRunning() }
}
/// Stop the session if it's running.
/// Caller must be on sessionQueue.
func stop() {
if av.isRunning { av.stopRunning() }
}
}