import SwiftUI import SwiftData // MARK: - ActiveRecordingView /// The live recording screen showing real-time waveform, elapsed time, /// and quick-action buttons for adding photos and notes during a session. /// Fully redesigned to use a minimalist business-focused line-drawn UI. struct ActiveRecordingView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss let session: CelestiaSession var initialDuration: TimeInterval = 0 let recordingSource: RecordingSourceChoice var onFinishRecording: (() -> Void)? = nil @State private var recordingVM: RecordingViewModel @State private var showNoteSheet = false @State private var showCamera = false @State private var noteText = "" @State private var latestEvent: CelestiaTimelineEvent? @State private var latestEventVisible = false @State private var isEndingRecording = false @State private var stopErrorMessage: String? init( session: CelestiaSession, initialDuration: TimeInterval = 0, recordingSource: RecordingSourceChoice = .iPhone, onFinishRecording: (() -> Void)? = nil ) { self.session = session self.initialDuration = initialDuration self.recordingSource = recordingSource self.onFinishRecording = onFinishRecording _recordingVM = State(initialValue: RecordingViewModel(source: recordingSource)) } var body: some View { ZStack { // Dynamic clean background Color.spaceBlack .ignoresSafeArea() VStack(spacing: 0) { Spacer() .frame(height: 20) // Top Header Block (Timecode + Indicator) VStack(spacing: 16) { timecodeSection recordingIndicator } .padding(.bottom, 24) // Live Waveform waveformSection .padding(.horizontal, 24) .padding(.bottom, 20) // Latest Event Card latestEventCard .padding(.horizontal, 24) .padding(.bottom, 20) Spacer() // Action Buttons actionButtons .padding(.horizontal, 48) .padding(.bottom, 32) // End Recording endRecordingButton .padding(.bottom, 24) } } .toolbar(.hidden, for: .tabBar) .toolbar(.hidden, for: .navigationBar) .navigationBarHidden(true) .navigationBarBackButtonHidden(true) .sheet(isPresented: $showNoteSheet) { noteInputSheet } .fullScreenCover(isPresented: $showCamera) { ImagePicker(sourceType: UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary) { image in saveImageAndAddEvent(image) } } .onAppear { UIApplication.shared.isIdleTimerDisabled = true recordingVM.startRecording(initialDuration: initialDuration) } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false if recordingVM.isRecording { recordingVM.stopRecording() } } .alert("无法结束设备录音", isPresented: Binding( get: { stopErrorMessage != nil }, set: { if !$0 { stopErrorMessage = nil } } )) { Button("重试") { endRecording() } Button("继续录音", role: .cancel) { stopErrorMessage = nil } } message: { Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。") } } // MARK: - Timecode private var timecodeSection: some View { Text(recordingVM.elapsedTimeFormatted) .font(.system(size: 52, weight: .light)) .monospacedDigit() .foregroundStyle(Color.primary) .contentTransition(.numericText(countsDown: false)) .frame(height: 64) } // MARK: - Recording Indicator private var recordingIndicator: some View { VStack(spacing: 8) { HStack(spacing: 8) { TimelineView(.periodic(from: .now, by: 1.0)) { context in let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0 Circle() .fill(recordingVM.isRecording ? Color.recordingRed : Color.secondary) .frame(width: 8, height: 8) .opacity(recordingVM.isRecording ? (isEven ? 1.0 : 0.3) : 0.6) .animation(.easeInOut(duration: 0.5), value: isEven) } .frame(width: 10, height: 10) Text(recordingVM.isPaused ? "已暂停" : recordingVM.statusMessage) .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundStyle(recordingVM.isRecording ? Color.recordingRed : Color.secondary) .tracking(1.2) Text("· \(session.title)") .font(.system(size: 11, weight: .regular)) .foregroundStyle(Color.secondary.opacity(0.7)) .lineLimit(1) } HStack(spacing: 6) { Image(systemName: recordingSource.systemImage) .font(.system(size: 11, weight: .medium)) Text("当前录音设备:\(recordingVM.sourceDisplayName)") .font(.system(size: 11, weight: .medium)) } .foregroundStyle(Color.primary.opacity(0.85)) if let error = recordingVM.errorMessage { Text(error) .font(.system(size: 10)) .foregroundStyle(Color.recordingRed) .multilineTextAlignment(.center) .lineLimit(2) } } .padding(.horizontal, 10) .padding(.vertical, 8) .businessBorder(cornerRadius: 6) } // MARK: - Waveform private var waveformSection: some View { VStack(spacing: 0) { LiveWaveformView(samples: recordingVM.waveformSamples) .frame(height: 90) // Graphical VU Meter + Numeric Display HStack(spacing: 10) { Text("音量电平") .font(.system(size: 9, weight: .semibold, design: .monospaced)) .foregroundStyle(Color.secondary.opacity(0.6)) .tracking(1.5) AmplitudeLevelMeterView(amplitude: recordingVM.currentAmplitude) Spacer(minLength: 0) Text(String(format: "%02.0f%%", recordingVM.currentAmplitude * 100)) .font(.system(size: 9, weight: .medium, design: .monospaced)) .foregroundStyle(recordingVM.currentAmplitude > 0.85 ? Color.recordingRed : Color.secondary) } .padding(.horizontal, 4) .padding(.top, 10) } .frame(height: 114) } // MARK: - Latest Event Card private var latestEventCard: some View { ZStack { // Fix: Use ZStack instead of Group to enforce the fixed frame even when empty if let event = latestEvent, latestEventVisible { HStack(spacing: 12) { Image(systemName: event.eventIcon) .font(.system(size: 12)) .foregroundStyle(Color.primary) .frame(width: 28, height: 28) .businessBorder(cornerRadius: 14) VStack(alignment: .leading, spacing: 2) { Text(eventLabel(for: event.eventType)) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Color.primary) if let text = event.textContent { Text(text) .font(.system(size: 12)) .foregroundStyle(Color.secondary) .lineLimit(1) } } Spacer() Text(event.relativeTimeFormatted) .font(.system(size: 11, weight: .regular, design: .monospaced)) .foregroundStyle(Color.secondary) } .padding(12) .background(Color.cardBackground.opacity(0.4)) .businessBorder(cornerRadius: 8) .transition(.asymmetric( insertion: .move(edge: .bottom).combined(with: .opacity), removal: .opacity )) } } .frame(height: 56) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: latestEvent?.id) } // MARK: - Action Buttons private var actionButtons: some View { HStack(spacing: 40) { // Camera button actionButton( icon: "camera", label: "拍照打点" ) { capturePhoto() } // Note button actionButton( icon: "doc.text", label: "文字笔记" ) { showNoteSheet = true } } } private func actionButton( icon: String, label: String, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(spacing: 10) { ZStack { Circle() .fill(Color.primary.opacity(0.02)) .frame(width: 60, height: 60) .businessBorder(cornerRadius: 30) Image(systemName: icon) .font(.system(size: 20, weight: .light)) .foregroundStyle(Color.primary) } Text(label) .font(.system(size: 11, weight: .regular)) .foregroundStyle(Color.secondary) } } .buttonStyle(.plain) } // MARK: - End Recording Button private var endRecordingButton: some View { Button { endRecording() } label: { VStack(spacing: 6) { Text("双击结束录制") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.recordingRed) .padding(.horizontal, 24) .padding(.vertical, 10) .businessBorder(cornerRadius: 8) Text("双击即可完成本次现场录音") .font(.system(size: 8, weight: .medium, design: .monospaced)) .foregroundStyle(Color.secondary.opacity(0.5)) .tracking(1.5) } } .highPriorityGesture( TapGesture(count: 2).onEnded { endRecording() } ) .buttonStyle(.plain) .disabled(isEndingRecording) } // MARK: - Note Input Sheet private var noteInputSheet: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() VStack(spacing: 20) { TextField("输入笔记内容...", text: $noteText, axis: .vertical) .textFieldStyle(.plain) .font(.system(size: 15)) .foregroundStyle(Color.primary) .padding(14) .frame(minHeight: 120, alignment: .top) .background(Color.cardBackground.opacity(0.5)) .businessBorder(cornerRadius: 8) Button { addNote() } label: { Text("保存笔记") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.spaceBlack) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color.primary) .cornerRadius(8) } .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) Spacer() } .padding(24) } .navigationTitle("添加笔记") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { showNoteSheet = false noteText = "" } .foregroundStyle(Color.secondary) } } } .presentationDetents([.medium]) .presentationDragIndicator(.visible) .presentationBackground(Color.spaceBlack) } // MARK: - Actions private func capturePhoto() { showCamera = true } private func saveImageAndAddEvent(_ image: UIImage) { guard let data = image.jpegData(compressionQuality: 0.8) else { return } let filename = "photo_\(UUID().uuidString).jpg" let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let fileURL = documentsURL.appendingPathComponent(filename) do { try data.write(to: fileURL) let event = recordingVM.addPhotoEvent(to: session, localFilePath: filename) HapticManager.trigger(.photoCapture) showLatestEvent(event) } catch { print("[ActiveRecordingView] Failed to save captured photo: \(error.localizedDescription)") } } private func addNote() { let text = noteText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } let event = recordingVM.addNoteEvent(to: session, text: text) HapticManager.trigger(.noteAdded) showLatestEvent(event) noteText = "" showNoteSheet = false } private func endRecording() { guard !isEndingRecording else { return } isEndingRecording = true recordingVM.stopRecording { result in switch result { case .success(let confirmedURL): finalizeRecording(newRecordedURL: confirmedURL ?? recordingVM.outputFileURL) case .failure(let error): isEndingRecording = false stopErrorMessage = error.localizedDescription } } } private func finalizeRecording(newRecordedURL: URL?) { let existingPath = session.localAudioPath let existingURL = AudioPathHelper.resolveURL(for: existingPath) let totalRecordedSeconds = recordingVM.elapsedTime if let newRecordedURL = newRecordedURL { Task { @MainActor in let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL) session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path) session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds) try? modelContext.save() HapticManager.trigger(.recordStop) isEndingRecording = false dismiss() onFinishRecording?() } } else { if existingURL != nil { session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds) try? modelContext.save() } else { session.endTime = Date() try? modelContext.save() } HapticManager.trigger(.recordStop) isEndingRecording = false dismiss() onFinishRecording?() } } private func showLatestEvent(_ event: CelestiaTimelineEvent) { withAnimation { latestEvent = event latestEventVisible = true } // Auto-dismiss after a few seconds DispatchQueue.main.asyncAfter(deadline: .now() + 4) { withAnimation { latestEventVisible = false } } } // MARK: - Helpers private func eventLabel(for type: String) -> String { switch type { case "PHOTO": return "已捕获照片" case "NOTE": return "已保存笔记" case "MARKER": return "标记点" case "VOICE": return "音轨事件" default: return "事件" } } } // MARK: - Amplitude Level Meter View /// A minimalist graphical VU meter bar visualizing real-time audio amplitude. private struct AmplitudeLevelMeterView: View { let amplitude: Float // 0.0 to 1.0 private let totalSegments: Int = 16 var body: some View { HStack(spacing: 3) { ForEach(0.. threshold let isPeak = index >= totalSegments - 2 RoundedRectangle(cornerRadius: 1) .fill( isFilled ? (isPeak ? Color.recordingRed : Color.primary.opacity(0.85)) : Color.primary.opacity(0.12) ) .frame(height: isFilled ? (isPeak ? 7 : 5) : 3) .animation(.spring(response: 0.15, dampingFraction: 0.75), value: amplitude) } } } } // MARK: - Preview #Preview { let session = CelestiaSession(title: "Preview Session") ActiveRecordingView(session: session) .modelContainer(for: CelestiaSession.self, inMemory: true) }