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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,3 +7,8 @@
|
||||
|
||||
build/
|
||||
.cxx/
|
||||
|
||||
# Mirror destinations populated by `prepare_command` in the iOS/macOS
|
||||
# podspecs from `darwin/Camera/` — see those podspecs for context.
|
||||
ios/Classes/Camera-shared/
|
||||
macos/Classes/Camera-shared/
|
||||
@@ -1,5 +1,9 @@
|
||||
import AVFoundation
|
||||
#if canImport(UIKit)
|
||||
import Flutter
|
||||
#else
|
||||
import FlutterMacOS
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
/// One per `UxCameraController` on the Dart side. Owns its
|
||||
@@ -1,6 +1,9 @@
|
||||
import AVFoundation
|
||||
#if canImport(UIKit)
|
||||
import Flutter
|
||||
import UIKit
|
||||
#else
|
||||
import FlutterMacOS
|
||||
#endif
|
||||
|
||||
/// `ux/camera` + `ux/camera/events` registrar. Routes channel calls
|
||||
/// to per-handle [CameraInstance]s. Enforces device + audio claims
|
||||
@@ -22,11 +25,15 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
|
||||
private var eventSink: FlutterEventSink?
|
||||
|
||||
public func register(with registrar: FlutterPluginRegistrar) {
|
||||
textureRegistry = registrar.textures()
|
||||
// `uxTextures` / `uxMessenger` are per-platform shims —
|
||||
// see `FlutterRegistrar+iOS.swift` / `…+macOS.swift`. iOS has
|
||||
// them as methods, macOS as properties; the extensions paper
|
||||
// over that so this call site stays platform-agnostic.
|
||||
textureRegistry = registrar.uxTextures
|
||||
|
||||
let methods = FlutterMethodChannel(
|
||||
name: "ux/camera",
|
||||
binaryMessenger: registrar.messenger()
|
||||
binaryMessenger: registrar.uxMessenger
|
||||
)
|
||||
methods.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call, result: result)
|
||||
@@ -34,7 +41,7 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
|
||||
|
||||
let events = FlutterEventChannel(
|
||||
name: "ux/camera/events",
|
||||
binaryMessenger: registrar.messenger()
|
||||
binaryMessenger: registrar.uxMessenger
|
||||
)
|
||||
events.setStreamHandler(self)
|
||||
}
|
||||
@@ -267,9 +274,11 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
|
||||
|
||||
case "openSettings":
|
||||
DispatchQueue.main.async {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
// Per-platform helper: iOS opens app-specific Settings
|
||||
// via UIApplication; macOS opens the Camera privacy
|
||||
// pane via NSWorkspace. See `CameraSettings.swift` in
|
||||
// each platform's Classes/Camera/ folder.
|
||||
CameraSettings.openAppSettings()
|
||||
result(nil)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,20 @@ import Foundation
|
||||
|
||||
/// Thin wrapper around `AVCaptureSession` that owns the lifecycle
|
||||
/// helpers and observes the runtime-error / interruption
|
||||
/// notifications, surfacing them through closures so
|
||||
/// [CameraInstance] doesn't repeat the boilerplate.
|
||||
/// 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
|
||||
|
||||
@@ -21,26 +28,18 @@ final class CameraSession {
|
||||
/// 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`).
|
||||
/// String describes the reason — `"videoDeviceInUseByAnotherClient"`,
|
||||
/// `"audioDeviceInUseByAnotherClient"`, `"videoDeviceNotAvailableInBackground"`, etc.
|
||||
/// iOS only — never fires on macOS.
|
||||
var onInterrupted: ((String) -> Void)?
|
||||
|
||||
/// Called on `.main` when an earlier interruption ends.
|
||||
/// Called on `.main` when an earlier interruption ends. iOS only.
|
||||
var onResumed: (() -> Void)?
|
||||
|
||||
private var runtimeErrorObserver: NSObjectProtocol?
|
||||
private var interruptedObserver: NSObjectProtocol?
|
||||
private var resumedObserver: NSObjectProtocol?
|
||||
var runtimeErrorObserver: NSObjectProtocol?
|
||||
var interruptedObserver: NSObjectProtocol?
|
||||
var resumedObserver: NSObjectProtocol?
|
||||
|
||||
init() {
|
||||
av = AVCaptureSession()
|
||||
|
||||
// Telegram + camera_avfoundation both set this — keeps
|
||||
// AVFoundation from yanking our audio session category out
|
||||
// from under the app.
|
||||
av.automaticallyConfiguresApplicationAudioSession = false
|
||||
av.usesApplicationAudioSession = true
|
||||
|
||||
runtimeErrorObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionRuntimeError,
|
||||
object: av,
|
||||
@@ -50,24 +49,7 @@ final class CameraSession {
|
||||
?? NSError(domain: "ux.camera", code: -1)
|
||||
self?.onRuntimeError?(error)
|
||||
}
|
||||
|
||||
interruptedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionWasInterrupted,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let reason = note.userInfo?[AVCaptureSessionInterruptionReasonKey]
|
||||
as? Int ?? 0
|
||||
self?.onInterrupted?(reasonName(for: reason))
|
||||
}
|
||||
|
||||
resumedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionInterruptionEnded,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.onResumed?()
|
||||
}
|
||||
setupPlatform()
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -97,26 +79,3 @@ final class CameraSession {
|
||||
if av.isRunning { av.stopRunning() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the integer reason that comes with
|
||||
/// `AVCaptureSessionWasInterrupted`. Used in the event payload sent
|
||||
/// to Dart.
|
||||
private func reasonName(for code: Int) -> String {
|
||||
guard let reason = AVCaptureSession.InterruptionReason(rawValue: code) else {
|
||||
return "unknown"
|
||||
}
|
||||
switch reason {
|
||||
case .videoDeviceNotAvailableInBackground:
|
||||
return "videoDeviceNotAvailableInBackground"
|
||||
case .audioDeviceInUseByAnotherClient:
|
||||
return "audioDeviceInUseByAnotherClient"
|
||||
case .videoDeviceInUseByAnotherClient:
|
||||
return "videoDeviceInUseByAnotherClient"
|
||||
case .videoDeviceNotAvailableWithMultipleForegroundApps:
|
||||
return "videoDeviceNotAvailableWithMultipleForegroundApps"
|
||||
case .videoDeviceNotAvailableDueToSystemPressure:
|
||||
return "videoDeviceNotAvailableDueToSystemPressure"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
37
darwin/Camera/DeviceOrientation.swift
Normal file
37
darwin/Camera/DeviceOrientation.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import AVFoundation
|
||||
|
||||
/// Mirrors Flutter's `DeviceOrientation` — the four cardinal values
|
||||
/// that travel over the `ux/camera` channel as wire strings. `public`
|
||||
/// so the host app's XCTest target can verify the
|
||||
/// `DeviceOrientationFlutter` / `AVCaptureVideoOrientation` mapping
|
||||
/// without `@testable import`.
|
||||
///
|
||||
/// The enum is platform-shared; the listener that turns physical
|
||||
/// rotation into a stream of these values is platform-specific —
|
||||
/// see `ios/Classes/Camera/DeviceOrientationBridge.swift` for the
|
||||
/// iOS implementation and `macos/Classes/Camera/DeviceOrientationBridge.swift`
|
||||
/// for the macOS one (no-op; desktops don't rotate).
|
||||
public enum DeviceOrientationFlutter: String {
|
||||
case portraitUp
|
||||
case landscapeLeft
|
||||
case portraitDown
|
||||
case landscapeRight
|
||||
|
||||
/// Parse a wire string. Returns `.portraitUp` for unknown inputs
|
||||
/// (matches the Dart-side fallback in `MethodChannelUxCameraBackend`).
|
||||
public static func parse(_ raw: String?) -> DeviceOrientationFlutter {
|
||||
return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp
|
||||
}
|
||||
|
||||
/// AVFoundation video orientation. Translates Flutter's portrait-
|
||||
/// relative convention to AVFoundation's hardware-relative one.
|
||||
/// Used to drive `AVCaptureConnection.videoOrientation`.
|
||||
public var avVideoOrientation: AVCaptureVideoOrientation {
|
||||
switch self {
|
||||
case .portraitUp: return .portrait
|
||||
case .portraitDown: return .portraitUpsideDown
|
||||
case .landscapeLeft: return .landscapeRight
|
||||
case .landscapeRight: return .landscapeLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,16 @@ final class PhotoOutput {
|
||||
}
|
||||
|
||||
let settings = AVCapturePhotoSettings()
|
||||
if avOutput.supportedFlashModes.contains(flashMode) {
|
||||
// `supportedFlashModes` arrived in macOS 11; `flashMode` setter
|
||||
// in macOS 13. iOS has both since iOS 10. Gated via Swift
|
||||
// availability so we don't have to bump the macOS deployment
|
||||
// target just to use a flash that almost no Mac has anyway.
|
||||
if #available(macOS 11.0, *),
|
||||
avOutput.supportedFlashModes.contains(flashMode) {
|
||||
if #available(macOS 13.0, *) {
|
||||
settings.flashMode = flashMode
|
||||
}
|
||||
}
|
||||
|
||||
let delegate = PhotoCaptureDelegate { [weak self] result in
|
||||
// Reset orientation on the photo connection so a future
|
||||
@@ -1,6 +1,10 @@
|
||||
import AVFoundation
|
||||
import CoreVideo
|
||||
#if canImport(UIKit)
|
||||
import Flutter
|
||||
#else
|
||||
import FlutterMacOS
|
||||
#endif
|
||||
|
||||
/// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`.
|
||||
///
|
||||
@@ -10,6 +10,10 @@ import AVFoundation
|
||||
/// `automaticallyConfiguresApplicationAudioSession = false` +
|
||||
/// `usesApplicationAudioSession = true` (set in [CameraSession.init])
|
||||
/// so AVCaptureSession doesn't yank the category back.
|
||||
///
|
||||
/// iOS-only. The macOS counterpart lives in
|
||||
/// `macos/Classes/Camera/AudioSession.swift` and is a no-op —
|
||||
/// `AVCaptureSession` manages its own audio routing on macOS.
|
||||
enum AudioSession {
|
||||
/// Upgrade the shared category to include `.playAndRecord` (and
|
||||
/// the given options union'd with whatever's already set). No-op
|
||||
|
||||
59
ios/Classes/Camera/CameraSession+iOS.swift
Normal file
59
ios/Classes/Camera/CameraSession+iOS.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// iOS-only platform setup for [CameraSession]: enables the
|
||||
/// "application owns the AVAudioSession" flags (so AVCaptureSession
|
||||
/// doesn't yank our `.playAndRecord` category back), and subscribes
|
||||
/// to `AVCaptureSessionWasInterrupted` / `…InterruptionEnded`
|
||||
/// notifications (which only fire on iOS — `AVCaptureSession.InterruptionReason`
|
||||
/// isn't even declared on macOS).
|
||||
extension CameraSession {
|
||||
func setupPlatform() {
|
||||
// Telegram + camera_avfoundation both set these — keeps
|
||||
// AVFoundation from yanking our audio session category out
|
||||
// from under the app. iOS-only properties on AVCaptureSession.
|
||||
av.automaticallyConfiguresApplicationAudioSession = false
|
||||
av.usesApplicationAudioSession = true
|
||||
|
||||
interruptedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionWasInterrupted,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let code = note.userInfo?[AVCaptureSessionInterruptionReasonKey]
|
||||
as? Int ?? 0
|
||||
self?.onInterrupted?(reasonName(for: code))
|
||||
}
|
||||
|
||||
resumedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVCaptureSessionInterruptionEnded,
|
||||
object: av,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.onResumed?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the integer reason that comes with
|
||||
/// `AVCaptureSessionWasInterrupted`. Used in the event payload sent
|
||||
/// to Dart.
|
||||
private func reasonName(for code: Int) -> String {
|
||||
guard let reason = AVCaptureSession.InterruptionReason(rawValue: code) else {
|
||||
return "unknown"
|
||||
}
|
||||
switch reason {
|
||||
case .videoDeviceNotAvailableInBackground:
|
||||
return "videoDeviceNotAvailableInBackground"
|
||||
case .audioDeviceInUseByAnotherClient:
|
||||
return "audioDeviceInUseByAnotherClient"
|
||||
case .videoDeviceInUseByAnotherClient:
|
||||
return "videoDeviceInUseByAnotherClient"
|
||||
case .videoDeviceNotAvailableWithMultipleForegroundApps:
|
||||
return "videoDeviceNotAvailableWithMultipleForegroundApps"
|
||||
case .videoDeviceNotAvailableDueToSystemPressure:
|
||||
return "videoDeviceNotAvailableDueToSystemPressure"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
14
ios/Classes/Camera/CameraSettings.swift
Normal file
14
ios/Classes/Camera/CameraSettings.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import UIKit
|
||||
|
||||
/// Deep-link the user into this app's Settings entry. iOS exposes a
|
||||
/// dedicated `openSettingsURLString` that lands directly on the
|
||||
/// per-app permission pane. Called from the shared
|
||||
/// `CameraPlugin.handle("openSettings")`.
|
||||
enum CameraSettings {
|
||||
@MainActor
|
||||
static func openAppSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// Translates between Flutter's `DeviceOrientation` (4 enum values
|
||||
/// shipped as strings across the channel) and AVFoundation's
|
||||
/// `AVCaptureVideoOrientation`, and bridges physical-device rotation
|
||||
/// notifications to a closure callback.
|
||||
///
|
||||
/// Observed orientation source is `UIDevice.current.orientation` —
|
||||
/// independent of any UI orientation lock, so this fires even while
|
||||
/// the app's window is portrait-locked. `.faceUp` / `.faceDown` are
|
||||
/// ignored (no useful direction).
|
||||
/// Bridges physical-device rotation notifications to a closure callback.
|
||||
/// Observed source is `UIDevice.current.orientation` — independent of
|
||||
/// any UI orientation lock, so this fires even while the app's window
|
||||
/// is portrait-locked. `.faceUp` / `.faceDown` are ignored (no useful
|
||||
/// direction).
|
||||
///
|
||||
/// `UIDevice.beginGeneratingDeviceOrientationNotifications()` must be
|
||||
/// called on main, balanced with `end…()`; this class enforces both.
|
||||
///
|
||||
/// iOS-only. The macOS counterpart in
|
||||
/// `macos/Classes/Camera/DeviceOrientationBridge.swift` is a no-op —
|
||||
/// desktops don't rotate, so `current` stays at the initial
|
||||
/// `portraitUp` and the listener never fires.
|
||||
final class DeviceOrientationBridge {
|
||||
typealias Listener = (DeviceOrientationFlutter) -> Void
|
||||
|
||||
@@ -74,23 +75,7 @@ final class DeviceOrientationBridge {
|
||||
deinit { stop() }
|
||||
}
|
||||
|
||||
/// Mirrors Flutter's `DeviceOrientation` — the four cardinal values
|
||||
/// that travel over the `ux/camera` channel as wire strings. `public`
|
||||
/// so the host app's XCTest target can verify the
|
||||
/// `DeviceOrientationFlutter` / `AVCaptureVideoOrientation` mapping
|
||||
/// without `@testable import`.
|
||||
public enum DeviceOrientationFlutter: String {
|
||||
case portraitUp
|
||||
case landscapeLeft
|
||||
case portraitDown
|
||||
case landscapeRight
|
||||
|
||||
/// Parse a wire string. Returns `.portraitUp` for unknown inputs
|
||||
/// (matches the Dart-side fallback in `MethodChannelUxCameraBackend`).
|
||||
public static func parse(_ raw: String?) -> DeviceOrientationFlutter {
|
||||
return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp
|
||||
}
|
||||
|
||||
extension DeviceOrientationFlutter {
|
||||
/// `UIDeviceOrientation` → Flutter convention is a direct 1:1 by
|
||||
/// name. Despite the AV-side flip, `UIDeviceOrientation.landscapeLeft`
|
||||
/// and Flutter's `landscapeLeft` describe the same physical pose
|
||||
@@ -105,16 +90,4 @@ public enum DeviceOrientationFlutter: String {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// AVFoundation video orientation. Translates Flutter's portrait-
|
||||
/// relative convention to AVFoundation's hardware-relative one.
|
||||
/// Used to drive `AVCaptureConnection.videoOrientation`.
|
||||
public var avVideoOrientation: AVCaptureVideoOrientation {
|
||||
switch self {
|
||||
case .portraitUp: return .portrait
|
||||
case .portraitDown: return .portraitUpsideDown
|
||||
case .landscapeLeft: return .landscapeRight
|
||||
case .landscapeRight: return .landscapeLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
ios/Classes/Camera/FlutterRegistrar+iOS.swift
Normal file
12
ios/Classes/Camera/FlutterRegistrar+iOS.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Flutter
|
||||
|
||||
/// Bridges the per-platform difference in `FlutterPluginRegistrar`:
|
||||
/// `textures` and `messenger` are **methods** on the iOS variant
|
||||
/// (`func textures() -> ...`) but **properties** on the macOS variant
|
||||
/// (`var textures: ...`). Shared `CameraPlugin` calls
|
||||
/// `registrar.uxTextures` and `registrar.uxMessenger` so the call
|
||||
/// site stays platform-agnostic.
|
||||
extension FlutterPluginRegistrar {
|
||||
var uxTextures: FlutterTextureRegistry { textures() }
|
||||
var uxMessenger: FlutterBinaryMessenger { messenger() }
|
||||
}
|
||||
@@ -6,6 +6,12 @@ Pod::Spec.new do |s|
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Swipelab' => 'hello@swipelab.co' }
|
||||
s.source = { :path => '.' }
|
||||
# Mirror the shared `darwin/Camera/` Swift files into a local
|
||||
# `Classes/Camera-shared/` so CocoaPods picks them up via the
|
||||
# normal glob — neither symlinks nor `../` escapes work
|
||||
# (Pathname.glob bails on both). The mirror runs on every
|
||||
# `pod install`; the destination is `.gitignore`'d.
|
||||
s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared'
|
||||
s.source_files = 'Classes/**/*.swift'
|
||||
s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo', 'Photos', 'PhotosUI']
|
||||
s.dependency 'Flutter'
|
||||
|
||||
9
macos/Classes/Camera/AudioSession.swift
Normal file
9
macos/Classes/Camera/AudioSession.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
/// macOS counterpart of `ios/Classes/Camera/AudioSession.swift`.
|
||||
/// `AVAudioSession` is an iOS/tvOS/watchOS concept — macOS apps have
|
||||
/// no such global category state. `AVCaptureSession` configures its
|
||||
/// own audio routing on macOS, so this is a pure no-op.
|
||||
enum AudioSession {
|
||||
static func upgradeForRecording() {}
|
||||
}
|
||||
22
macos/Classes/Camera/CameraSession+macOS.swift
Normal file
22
macos/Classes/Camera/CameraSession+macOS.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// macOS counterpart of `CameraSession+iOS.swift`. Intentionally a
|
||||
/// no-op:
|
||||
///
|
||||
/// - `automaticallyConfiguresApplicationAudioSession` /
|
||||
/// `usesApplicationAudioSession` are iOS-only properties on
|
||||
/// `AVCaptureSession`. macOS doesn't have an app-wide
|
||||
/// `AVAudioSession`, so there's nothing to "configure".
|
||||
/// - `AVCaptureSessionWasInterrupted` and `InterruptionReason` are
|
||||
/// iOS/tvOS/watchOS only — desktop AVCaptureSessions never get
|
||||
/// "interrupted by a phone call" or "displaced by another app's
|
||||
/// exclusive camera hold" in the way iOS does.
|
||||
///
|
||||
/// `onInterrupted` and `onResumed` on [CameraSession] are present
|
||||
/// for surface parity but stay silent on macOS.
|
||||
extension CameraSession {
|
||||
func setupPlatform() {
|
||||
// Intentionally empty.
|
||||
}
|
||||
}
|
||||
16
macos/Classes/Camera/CameraSettings.swift
Normal file
16
macos/Classes/Camera/CameraSettings.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import AppKit
|
||||
|
||||
/// macOS counterpart of the iOS `CameraSettings.openAppSettings`.
|
||||
/// There's no per-app deep link on macOS; we go straight to the
|
||||
/// Camera privacy pane in System Settings. The URL scheme has been
|
||||
/// stable since macOS 10.14.
|
||||
enum CameraSettings {
|
||||
@MainActor
|
||||
static func openAppSettings() {
|
||||
if let url = URL(
|
||||
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera"
|
||||
) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
macos/Classes/Camera/DeviceOrientationBridge.swift
Normal file
19
macos/Classes/Camera/DeviceOrientationBridge.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
/// macOS counterpart of `ios/Classes/Camera/DeviceOrientationBridge.swift`.
|
||||
/// Desktops don't rotate; the bridge is shaped identically to the iOS
|
||||
/// one so [CameraInstance] can call it without `#if` blocks, but
|
||||
/// `current` stays at `.portraitUp` and the listener never fires.
|
||||
final class DeviceOrientationBridge {
|
||||
typealias Listener = (DeviceOrientationFlutter) -> Void
|
||||
|
||||
private(set) var current: DeviceOrientationFlutter = .portraitUp
|
||||
|
||||
func start(listener: @escaping Listener) {
|
||||
// Intentionally empty — no rotation events on macOS.
|
||||
}
|
||||
|
||||
func stop() {
|
||||
// Intentionally empty — nothing to unwind.
|
||||
}
|
||||
}
|
||||
11
macos/Classes/Camera/FlutterRegistrar+macOS.swift
Normal file
11
macos/Classes/Camera/FlutterRegistrar+macOS.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import FlutterMacOS
|
||||
|
||||
/// macOS counterpart of `ios/Classes/Camera/FlutterRegistrar+iOS.swift`.
|
||||
/// On macOS, `FlutterPluginRegistrar.textures` and `.messenger` are
|
||||
/// properties (no parens) rather than methods. Shared `CameraPlugin`
|
||||
/// uses `registrar.uxTextures` and `registrar.uxMessenger` for
|
||||
/// surface parity.
|
||||
extension FlutterPluginRegistrar {
|
||||
var uxTextures: FlutterTextureRegistry { textures }
|
||||
var uxMessenger: FlutterBinaryMessenger { messenger }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
|
||||
FilePlugin(),
|
||||
ClipboardPlugin(),
|
||||
GalleryPlugin(),
|
||||
CameraPlugin(),
|
||||
]
|
||||
for plugin in plugins {
|
||||
plugin.register(with: registrar)
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ux'
|
||||
s.version = '0.6.0'
|
||||
s.summary = 'UX Kit — Flutter plugin: file share/open via Quick Look.'
|
||||
s.version = '0.9.0'
|
||||
s.summary = 'UX Kit — Flutter plugin: keyboard, sensor, file, QR scanner, and camera.'
|
||||
s.homepage = 'https://swipelab.co/ux.html'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Swipelab' => 'hello@swipelab.co' }
|
||||
s.source = { :path => '.' }
|
||||
# See the matching note in `ios/ux.podspec` — mirror the shared
|
||||
# `darwin/Camera/` Swift files into a local `Classes/Camera-shared/`
|
||||
# at install time so CocoaPods picks them up via the normal glob.
|
||||
s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared'
|
||||
s.source_files = 'Classes/**/*.swift'
|
||||
s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo']
|
||||
s.dependency 'FlutterMacOS'
|
||||
s.osx.deployment_target = '10.15'
|
||||
s.swift_version = '5.0'
|
||||
|
||||
Reference in New Issue
Block a user