193 lines
6.3 KiB
Swift
193 lines
6.3 KiB
Swift
import AVFoundation
|
|
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
@MainActor
|
|
struct VideoNoteCaptureView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@StateObject private var recorder = VideoNoteRecorder()
|
|
@State private var errorMessage: String?
|
|
@State private var isFinalizing = false
|
|
|
|
let onComplete: (VideoNoteRecorder.Recording) -> Void
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 22) {
|
|
ZStack {
|
|
Circle().fill(Color.black)
|
|
CameraPreview(session: recorder.session)
|
|
.clipShape(Circle())
|
|
if !recorder.isPrepared {
|
|
ProgressView("Подготовка камеры…")
|
|
.tint(.white)
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
.frame(maxWidth: 420)
|
|
.aspectRatio(1, contentMode: .fit)
|
|
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1))
|
|
|
|
Text(recordingStatus)
|
|
.font(.system(.title3, design: .monospaced).weight(.semibold))
|
|
.foregroundStyle(recorder.isRecording ? .red : .secondary)
|
|
|
|
Button {
|
|
guard recorder.isRecording, !isFinalizing else { return }
|
|
isFinalizing = true
|
|
Task { @MainActor in
|
|
await recorder.stop(
|
|
afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration
|
|
)
|
|
}
|
|
} label: {
|
|
ZStack {
|
|
Circle()
|
|
.stroke(Color.red, lineWidth: 4)
|
|
.frame(width: 76, height: 76)
|
|
if isFinalizing {
|
|
ProgressView()
|
|
.controlSize(.regular)
|
|
.tint(.red)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 7)
|
|
.fill(Color.red)
|
|
.frame(width: 32, height: 32)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!recorder.isRecording || isFinalizing)
|
|
.accessibilityLabel(isFinalizing ? "Видео завершается" : "Завершить запись")
|
|
|
|
Text("Запись началась. Нажмите красную кнопку, чтобы завершить и отправить.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(24)
|
|
.navigationTitle("Видеосообщение")
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Отмена") {
|
|
recorder.cancel()
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
recorder.onFinished = { result in
|
|
switch result {
|
|
case .success(let recording):
|
|
onComplete(recording)
|
|
dismiss()
|
|
case .failure(let error):
|
|
isFinalizing = false
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
do {
|
|
try await recorder.prepare()
|
|
guard !Task.isCancelled else { return }
|
|
try recorder.start()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
.onDisappear {
|
|
if recorder.isRecording {
|
|
recorder.cancel()
|
|
} else {
|
|
recorder.shutdown()
|
|
}
|
|
}
|
|
.alert("Luma", isPresented: Binding(
|
|
get: { errorMessage != nil },
|
|
set: { if !$0 { errorMessage = nil } }
|
|
)) {
|
|
Button("OK") { errorMessage = nil }
|
|
} message: {
|
|
Text(errorMessage ?? "Неизвестная ошибка")
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 540, minHeight: 650)
|
|
#endif
|
|
}
|
|
|
|
private func formatted(_ duration: TimeInterval) -> String {
|
|
let seconds = max(0, Int(duration.rounded(.down)))
|
|
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
|
}
|
|
|
|
private var recordingStatus: String {
|
|
if isFinalizing { return "Завершение…" }
|
|
if recorder.isRecording { return formatted(recorder.elapsed) }
|
|
return "Подготовка камеры…"
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
struct CameraPreview: UIViewRepresentable {
|
|
let session: AVCaptureSession
|
|
|
|
func makeUIView(context: Context) -> PreviewView {
|
|
let view = PreviewView()
|
|
view.previewLayer.session = session
|
|
view.previewLayer.videoGravity = .resizeAspectFill
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: PreviewView, context: Context) {
|
|
uiView.previewLayer.session = session
|
|
}
|
|
|
|
final class PreviewView: UIView {
|
|
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
|
|
|
var previewLayer: AVCaptureVideoPreviewLayer {
|
|
layer as! AVCaptureVideoPreviewLayer
|
|
}
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
struct CameraPreview: NSViewRepresentable {
|
|
let session: AVCaptureSession
|
|
|
|
func makeNSView(context: Context) -> PreviewView {
|
|
let view = PreviewView()
|
|
view.previewLayer.session = session
|
|
view.previewLayer.videoGravity = .resizeAspectFill
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: PreviewView, context: Context) {
|
|
nsView.previewLayer.session = session
|
|
}
|
|
|
|
final class PreviewView: NSView {
|
|
let previewLayer = AVCaptureVideoPreviewLayer()
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
wantsLayer = true
|
|
layer = previewLayer
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
return nil
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
previewLayer.frame = bounds
|
|
}
|
|
}
|
|
}
|
|
#endif
|