Skip to content

Commit 4528027

Browse files
feat: update CameraViewModel and ContentView for sponsored video improvements
1 parent 3e73d70 commit 4528027

2 files changed

Lines changed: 139 additions & 62 deletions

File tree

Camera/CameraViewModel.swift

Lines changed: 95 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import UIKit
1212
import Photos
1313
import CoreImage
1414
import CoreImage.CIFilterBuiltins
15+
import SwiftUI
1516

1617
final class CameraViewModel: NSObject, ObservableObject {
1718
@Published var isAuthorized = false
1819
@Published var isSessionRunning = false
20+
@Published var isCameraReady = false
1921
@Published var isRecording = false
2022
@Published var frameRateLabel: String = "60 fps"
2123
@Published var quickZoomIndex: Int = 1 // 0 -> 0.5x, 1 -> 1x, 2 -> 2x
@@ -28,6 +30,7 @@ final class CameraViewModel: NSObject, ObservableObject {
2830
@Published var teleprompterText: String = "Adicione seu roteiro aqui..."
2931
@Published var teleprompterSpeed: Double = 16.0 // points per second (default slower)
3032
@Published var teleprompterFontSize: CGFloat = 24.0 // default smaller
33+
@Published var isProcessingVideo: Bool = false
3134

3235
let controller = CaptureSessionController()
3336
private var recorder: SegmentedRecorder?
@@ -38,17 +41,45 @@ final class CameraViewModel: NSObject, ObservableObject {
3841

3942
override init() {
4043
super.init()
44+
loadTeleprompterSettingsFromUserDefaults()
45+
}
46+
47+
/// Load teleprompter text from UserDefaults (passed from Flutter)
48+
/// Auto-enables teleprompter if valid script is present
49+
private func loadTeleprompterSettingsFromUserDefaults() {
50+
if let savedText = UserDefaults.standard.string(forKey: "teleprompter_initial_text"),
51+
!savedText.isEmpty,
52+
savedText != "Adicione seu roteiro aqui..." {
53+
teleprompterText = savedText
54+
// Auto-enable teleprompter when a script is provided
55+
isTeleprompterOn = true
56+
}
4157
}
4258

4359
deinit {
44-
// Ensure brightness is restored if we used screen torch
45-
setScreenTorchEnabled(false)
60+
// Stop recording if active
61+
if let recorder, recorder.isRecording {
62+
recorder.stopCurrentSegment()
63+
}
4664

47-
// Remove orientation observer
48-
if let observer = orientationObserver {
49-
NotificationCenter.default.removeObserver(observer)
65+
// Stop the camera session
66+
controller.stopSession()
67+
68+
// Restore screen brightness if we modified it
69+
if let originalBrightness = savedScreenBrightness {
70+
let brightness = originalBrightness
71+
DispatchQueue.main.async {
72+
UIScreen.main.brightness = brightness
73+
}
74+
}
75+
76+
// Clean up temporary segment files in background
77+
let segmentURLs = segments.map { $0.url }
78+
DispatchQueue.global(qos: .background).async {
79+
for url in segmentURLs {
80+
try? FileManager.default.removeItem(at: url)
81+
}
5082
}
51-
UIDevice.current.endGeneratingDeviceOrientationNotifications()
5283
}
5384

5485
// MARK: - Grid
@@ -75,6 +106,12 @@ final class CameraViewModel: NSObject, ObservableObject {
75106
func start() {
76107
controller.startSession()
77108
isSessionRunning = true
109+
// Small delay to ensure session is fully running before showing preview
110+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
111+
withAnimation(.easeOut(duration: 0.3)) {
112+
self?.isCameraReady = true
113+
}
114+
}
78115
}
79116

80117
// MARK: - Filter
@@ -104,6 +141,13 @@ final class CameraViewModel: NSObject, ObservableObject {
104141
setScreenTorchEnabled(false)
105142
}
106143

144+
/// Safely dismisses the camera and notifies Flutter of cancellation.
145+
func dismissCamera() {
146+
// Notify Flutter immediately - let the plugin handle the dismiss
147+
// This must happen BEFORE we start any cleanup to avoid race conditions
148+
NotificationCenter.default.post(name: Notification.Name("conty.teleprompter.cancelled"), object: nil)
149+
}
150+
107151
func attachPreview(_ view: CameraPreviewView) {
108152
view.attach(session: controller.session)
109153
view.onTapToFocus = { [weak self] devicePoint in
@@ -285,13 +329,16 @@ extension CameraViewModel {
285329
try? FileManager.default.removeItem(at: url)
286330
}
287331
}
288-
332+
289333
func nextAction() {
290334
print("Next pressed with \(segments.count) takes")
291335

292336
guard !segments.isEmpty else { return }
337+
guard !isProcessingVideo else { return }
338+
339+
isProcessingVideo = true
293340

294-
// Concatenate all segments into a single video and save to gallery
341+
// Concatenate all segments into a single video and notify Flutter
295342
concatenateAndSaveSegments()
296343
}
297344

@@ -353,10 +400,13 @@ extension CameraViewModel {
353400
private func exportComposition(_ composition: AVMutableComposition) {
354401
guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
355402
print("Failed to create export session")
403+
DispatchQueue.main.async { self.isProcessingVideo = false }
356404
return
357405
}
358406

359-
let outputURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("final_video_\(UUID().uuidString).mov")
407+
// Use app documents directory so file persists for Flutter to use
408+
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
409+
let outputURL = docsDir.appendingPathComponent("final_video_\(UUID().uuidString).mov")
360410
exporter.outputURL = outputURL
361411
exporter.outputFileType = .mov
362412

@@ -370,71 +420,61 @@ extension CameraViewModel {
370420
case .completed:
371421
print("Video concatenation completed successfully")
372422
print("Output URL: \(outputURL)")
373-
self.saveVideoToPhotos(outputURL)
374-
375-
// CLEANUP segments ONLY after successful export
376-
for segment in self.segments {
377-
try? FileManager.default.removeItem(at: segment.url)
378-
}
379-
DispatchQueue.main.async {
380-
self.segments.removeAll()
381-
}
382-
383-
// Clean up temp file after a delay to ensure save completes
384-
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
385-
try? FileManager.default.removeItem(at: outputURL)
386-
}
423+
self.finishWithVideo(at: outputURL)
387424
case .failed, .cancelled:
388425
print("Video concatenation failed: \(exporter.error?.localizedDescription ?? "Unknown error")")
389426
if let error = exporter.error {
390427
print("Export error details: \(error)")
391428
}
429+
DispatchQueue.main.async { self.isProcessingVideo = false }
392430
default:
393431
print("Export in progress or unknown status")
394432
break
395433
}
396434
}
397435
} else {
398436
print("Applying filter (\(selectedFilter.displayName)) to concatenated video")
399-
applyFilter(selectedFilter, to: composition) { result in
437+
applyFilter(selectedFilter, to: composition) { [weak self] result in
438+
guard let self = self else { return }
400439
switch result {
401440
case .success(let filteredURL):
402-
print("Filter applied successfully, saving filtered video")
403-
self.saveVideoToPhotos(filteredURL)
404-
405-
// CLEANUP segments ONLY after successful filtered export
406-
for segment in self.segments {
407-
try? FileManager.default.removeItem(at: segment.url)
408-
}
409-
DispatchQueue.main.async {
410-
self.segments.removeAll()
411-
}
412-
413-
// Clean up temp files after a delay
414-
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
415-
try? FileManager.default.removeItem(at: filteredURL)
416-
try? FileManager.default.removeItem(at: outputURL)
417-
}
441+
print("Filter applied successfully")
442+
self.finishWithVideo(at: filteredURL)
443+
// Clean up unfiltered temp file
444+
try? FileManager.default.removeItem(at: outputURL)
418445
case .failure(let error):
419446
print("Filter application failed: \(error)")
420-
print("Saving unfiltered video instead")
421-
self.saveVideoToPhotos(outputURL)
422-
423-
// Even on failure, if we are saving the raw one, we cleanup segments after saving
424-
for segment in self.segments {
425-
try? FileManager.default.removeItem(at: segment.url)
426-
}
427-
DispatchQueue.main.async {
428-
self.segments.removeAll()
429-
}
430-
431-
// Clean up temp file after a delay
432-
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
433-
try? FileManager.default.removeItem(at: outputURL)
434-
}
447+
print("Using unfiltered video instead")
448+
self.finishWithVideo(at: outputURL)
435449
}
436450
}
437451
}
452+
453+
// Clean up individual segment files
454+
for segment in segments {
455+
try? FileManager.default.removeItem(at: segment.url)
456+
}
457+
458+
// Clear segments after processing
459+
DispatchQueue.main.async {
460+
self.segments.removeAll()
461+
}
462+
}
463+
464+
/// Finishes the recording flow by notifying Flutter with the video path
465+
private func finishWithVideo(at url: URL) {
466+
// Stop the camera session
467+
stop()
468+
469+
// Notify Flutter with the video path
470+
DispatchQueue.main.async {
471+
self.isProcessingVideo = false
472+
NotificationCenter.default.post(
473+
name: Notification.Name("conty.teleprompter.finished"),
474+
object: nil,
475+
userInfo: ["path": url.path, "savedToGallery": false]
476+
)
477+
}
438478
}
439479

440480
private func applyFilter(_ filterType: VideoFilter, to composition: AVMutableComposition, completion: @escaping (Result<URL, Error>) -> Void) {

Camera/ContentView.swift

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,27 @@ struct ContentView: View {
3636

3737
ZStack {
3838
CameraPreviewRepresentable(model: model)
39+
.opacity(model.isCameraReady ? 1 : 0)
40+
41+
// Loading overlay while camera initializes
42+
if !model.isCameraReady {
43+
ZStack {
44+
Color.black
45+
ProgressView()
46+
.progressViewStyle(CircularProgressViewStyle(tint: .white))
47+
.scaleEffect(1.2)
48+
}
49+
.transition(.opacity)
50+
}
3951

4052
// Grid overlay (rule of thirds) - clipped to preview
41-
if model.showGrid {
53+
if model.showGrid && model.isCameraReady {
4254
GridOverlay()
4355
.allowsHitTesting(false)
4456
}
4557

4658
// Lightweight overlay hint to approximate selected filter visually
47-
if let overlay = previewOverlayColor(for: model.selectedFilter) {
59+
if let overlay = previewOverlayColor(for: model.selectedFilter), model.isCameraReady {
4860
overlay
4961
.opacity(0.12)
5062
.allowsHitTesting(false)
@@ -56,6 +68,7 @@ struct ContentView: View {
5668
)
5769
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
5870
.offset(y: -30)
71+
.animation(.easeOut(duration: 0.3), value: model.isCameraReady)
5972

6073
Spacer()
6174
}
@@ -68,7 +81,7 @@ struct ContentView: View {
6881
if !model.isRecording {
6982
HStack {
7083
// Close button
71-
Button(action: { dismiss() }) {
84+
Button(action: { model.dismissCamera() }) {
7285
Image(systemName: "xmark")
7386
.font(.system(size: 14, weight: .semibold))
7487
.foregroundColor(.white)
@@ -318,18 +331,42 @@ struct ContentView: View {
318331
HStack {
319332
Spacer()
320333
Button(action: { model.nextAction() }) {
321-
Image(systemName: "arrow.right.circle.fill")
322-
.font(.system(size: 26, weight: .semibold))
323-
.foregroundColor(.white)
324-
.padding(11)
334+
if model.isProcessingVideo {
335+
ProgressView()
336+
.progressViewStyle(CircularProgressViewStyle(tint: .white))
337+
.padding(11)
338+
} else {
339+
Image(systemName: "arrow.right.circle.fill")
340+
.font(.system(size: 26, weight: .semibold))
341+
.foregroundColor(.white)
342+
.padding(11)
343+
}
325344
}
345+
.disabled(model.isProcessingVideo)
326346
.buttonStyle(GlassCircleButtonStyle(isProminent: true, accentColor: Color(red: 0x29/255.0, green: 0x84/255.0, blue: 0xf6/255.0)))
327347
.padding(.trailing, 12)
328348
.padding(.bottom, 10)
329349
.accessibilityLabel("Avançar")
330350
}
331351
}
332352
}
353+
354+
// Processing overlay
355+
if model.isProcessingVideo {
356+
Color.black.opacity(0.4)
357+
.ignoresSafeArea()
358+
.allowsHitTesting(true)
359+
360+
VStack(spacing: 16) {
361+
ProgressView()
362+
.progressViewStyle(CircularProgressViewStyle(tint: .white))
363+
.scaleEffect(1.5)
364+
Text("Preparando vídeo...")
365+
.font(.system(size: 16, weight: .medium))
366+
.foregroundColor(.white)
367+
}
368+
.transition(.opacity)
369+
}
333370
}
334371
.onAppear { model.requestPermissionsAndConfigure() }
335372
.onDisappear { model.stop() }

0 commit comments

Comments
 (0)