Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/CameraPreviewView.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)
}
}