196 lines
6.4 KiB
Swift
196 lines
6.4 KiB
Swift
import AVFoundation
|
|
import SwiftUI
|
|
|
|
// MARK: - CameraPreviewView
|
|
|
|
/// Live camera preview using AVCaptureSession, wrapped for SwiftUI.
|
|
///
|
|
/// Figma: Camera tile in attachment panel shows a real-time rear camera feed.
|
|
/// Uses `.medium` preset for minimal memory/battery usage (it's just a preview).
|
|
///
|
|
/// Graceful fallback: on Simulator or when camera unavailable, shows a dark
|
|
/// placeholder with a camera icon.
|
|
struct CameraPreviewView: UIViewRepresentable {
|
|
|
|
func makeUIView(context: Context) -> CameraPreviewUIView {
|
|
let view = CameraPreviewUIView()
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
|
|
|
|
static func dismantleUIView(_ uiView: CameraPreviewUIView, coordinator: ()) {
|
|
uiView.stopSession()
|
|
}
|
|
}
|
|
|
|
// MARK: - CameraPreviewUIView
|
|
|
|
/// UIKit view hosting AVCaptureVideoPreviewLayer for live camera feed.
|
|
///
|
|
/// Permission request is deferred to `didMoveToWindow()` — the system dialog
|
|
/// needs an active window to present on. Requesting in `init()` is too early.
|
|
final class CameraPreviewUIView: UIView {
|
|
|
|
private let captureSession = AVCaptureSession()
|
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
private var isSessionRunning = false
|
|
private var isSetUp = false
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = UIColor(white: 0.08, alpha: 1)
|
|
clipsToBounds = true
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
previewLayer?.frame = bounds
|
|
}
|
|
|
|
override func didMoveToWindow() {
|
|
super.didMoveToWindow()
|
|
if window != nil {
|
|
// First time: request permission + set up session
|
|
// Subsequent times: just resume
|
|
if !isSetUp {
|
|
isSetUp = true
|
|
requestAccessAndSetup()
|
|
} else {
|
|
startSession()
|
|
}
|
|
} else {
|
|
stopSession()
|
|
}
|
|
}
|
|
|
|
// MARK: - Permission + Session Setup
|
|
|
|
private func requestAccessAndSetup() {
|
|
#if targetEnvironment(simulator)
|
|
addPlaceholder()
|
|
return
|
|
#else
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
configureSession()
|
|
case .notDetermined:
|
|
// System dialog will present over the current window
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
DispatchQueue.main.async {
|
|
if granted {
|
|
self?.configureSession()
|
|
} else {
|
|
self?.addDeniedPlaceholder()
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
addDeniedPlaceholder()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Configures AVCaptureSession with rear camera input + preview layer.
|
|
/// Called only after camera authorization is confirmed.
|
|
private func configureSession() {
|
|
guard let device = AVCaptureDevice.default(
|
|
.builtInWideAngleCamera, for: .video, position: .back
|
|
) else {
|
|
addPlaceholder()
|
|
return
|
|
}
|
|
|
|
do {
|
|
let input = try AVCaptureDeviceInput(device: device)
|
|
captureSession.beginConfiguration()
|
|
captureSession.sessionPreset = .medium
|
|
if captureSession.canAddInput(input) {
|
|
captureSession.addInput(input)
|
|
}
|
|
captureSession.commitConfiguration()
|
|
|
|
let preview = AVCaptureVideoPreviewLayer(session: captureSession)
|
|
preview.videoGravity = .resizeAspectFill
|
|
preview.frame = bounds
|
|
layer.addSublayer(preview)
|
|
previewLayer = preview
|
|
|
|
startSession()
|
|
} catch {
|
|
addPlaceholder()
|
|
}
|
|
}
|
|
|
|
func startSession() {
|
|
guard !isSessionRunning, !captureSession.inputs.isEmpty else { return }
|
|
isSessionRunning = true
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
self?.captureSession.startRunning()
|
|
}
|
|
}
|
|
|
|
func stopSession() {
|
|
guard isSessionRunning else { return }
|
|
isSessionRunning = false
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
self?.captureSession.stopRunning()
|
|
}
|
|
}
|
|
|
|
// MARK: - Placeholders
|
|
|
|
/// Generic placeholder (simulator / no camera hardware).
|
|
private func addPlaceholder() {
|
|
let iconConfig = UIImage.SymbolConfiguration(pointSize: 32, weight: .regular)
|
|
let icon = UIImageView(image: UIImage(systemName: "camera.fill", withConfiguration: iconConfig))
|
|
icon.tintColor = UIColor.white.withAlphaComponent(0.5)
|
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(icon)
|
|
NSLayoutConstraint.activate([
|
|
icon.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
])
|
|
}
|
|
|
|
/// Placeholder shown when camera access is denied — includes "Tap to enable" hint.
|
|
private func addDeniedPlaceholder() {
|
|
let stack = UIStackView()
|
|
stack.axis = .vertical
|
|
stack.alignment = .center
|
|
stack.spacing = 4
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let iconConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
|
|
let icon = UIImageView(image: UIImage(systemName: "camera.fill", withConfiguration: iconConfig))
|
|
icon.tintColor = UIColor.white.withAlphaComponent(0.4)
|
|
|
|
let label = UILabel()
|
|
label.text = "Enable Camera"
|
|
label.font = .systemFont(ofSize: 10, weight: .medium)
|
|
label.textColor = UIColor.white.withAlphaComponent(0.4)
|
|
|
|
stack.addArrangedSubview(icon)
|
|
stack.addArrangedSubview(label)
|
|
addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
stack.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
])
|
|
|
|
// Tap opens Settings
|
|
let tap = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
|
addGestureRecognizer(tap)
|
|
}
|
|
|
|
@objc private func openSettings() {
|
|
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|