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 pendingNoteTimeMs: Double = 0 @State private var latestEvent: CelestiaTimelineEvent? @State private var latestEventVisible = false @State private var isEndingRecording = false @State private var stopErrorMessage: String? @State private var recordName: String @State private var showRecordNameEditor = false @State private var hasStartedLiveActivity = false 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)) _recordName = State(initialValue: session.title) } var body: some View { ZStack(alignment: .top) { // Dynamic clean background Color.spaceBlack .ignoresSafeArea() .contentShape(Rectangle()) .onTapGesture(count: 2) { endRecording() } VStack(spacing: 0) { recordNameSection .padding(.horizontal, 24) .padding(.top, 16) .padding(.bottom, 14) // 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) // Recording Controls recordingControlButtons .padding(.bottom, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .ignoresSafeArea(.keyboard, edges: .bottom) } .ignoresSafeArea(.keyboard, edges: .bottom) .toolbar(.hidden, for: .tabBar) .toolbar(.hidden, for: .navigationBar) .navigationBarHidden(true) .navigationBarBackButtonHidden(true) .sheet(isPresented: $showRecordNameEditor) { recordNameEditorSheet } .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) } .onChange(of: recordingVM.isRecording) { _, isRecording in guard isRecording, !hasStartedLiveActivity else { return } hasStartedLiveActivity = true RecordingLiveActivityManager.shared.start( sessionID: session.id, title: session.title, sourceName: recordingVM.sourceDisplayName, elapsedSeconds: recordingVM.elapsedTime ) } .onChange(of: recordingVM.isPaused) { _, isPaused in guard hasStartedLiveActivity else { return } RecordingLiveActivityManager.shared.update( elapsedSeconds: recordingVM.elapsedTime, isPaused: isPaused ) } .onOpenURL { url in handleRecordingURL(url) } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false commitRecordName() if recordingVM.isRecording { recordingVM.stopRecording() } if hasStartedLiveActivity { RecordingLiveActivityManager.shared.end(elapsedSeconds: recordingVM.elapsedTime) hasStartedLiveActivity = false } } .alert("无法结束设备录音", isPresented: Binding( get: { stopErrorMessage != nil }, set: { if !$0 { stopErrorMessage = nil } } )) { Button("重试") { endRecording() } Button("继续录音", role: .cancel) { stopErrorMessage = nil } } message: { Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。") } } // MARK: - Record Name private var recordNameSection: some View { Button { recordName = session.title showRecordNameEditor = true } label: { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { Text("记录名称") .font(.system(size: 10, weight: .semibold, design: .monospaced)) .tracking(1.4) Spacer() Image(systemName: "pencil") .font(.system(size: 11, weight: .semibold)) } .foregroundStyle(Color.secondary) Text(session.title) .font(.system(size: 19, weight: .semibold)) .foregroundStyle(Color.primary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) Rectangle() .fill(Color.primary.opacity(0.16)) .frame(height: 1) } .padding(.horizontal, 14) .padding(.vertical, 12) .background(Color.cardBackground.opacity(0.45)) .businessBorder(cornerRadius: 8) .contentShape(Rectangle()) } .buttonStyle(.plain) } private var recordNameEditorSheet: some View { RecordNameEditorSheet(recordName: $recordName) { saveRecordNameAndCloseEditor() } onCancel: { recordName = session.title showRecordNameEditor = false } } // 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 && !recordingVM.isPaused ? Color.recordingRed : Color.secondary) .frame(width: 8, height: 8) .opacity(recordingVM.isRecording && !recordingVM.isPaused ? (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 && !recordingVM.isPaused ? Color.recordingRed : Color.secondary) .tracking(1.2) } 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) { 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: "文字笔记" ) { presentNoteSheet() } } } 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: - Recording Controls private var recordingControlButtons: some View { HStack(spacing: 52) { recordingControlButton( icon: recordingVM.isPaused ? "play.fill" : "pause.fill", label: recordingVM.isPaused ? "继续记录" : "暂停", tint: .primary ) { toggleRecordingPause() } recordingControlButton( icon: "stop.fill", label: isEndingRecording ? "正在结束" : "结束记录", tint: .recordingRed, isProminent: true ) { endRecording() } } } private func recordingControlButton( icon: String, label: String, tint: Color, isProminent: Bool = false, action: @escaping () -> Void ) -> some View { Button(action: action) { VStack(spacing: 9) { ZStack { Circle() .fill(isProminent ? tint : Color.primary.opacity(0.02)) .frame(width: 64, height: 64) if !isProminent { Circle() .stroke(Color.primary.opacity(0.18), lineWidth: 1) .frame(width: 64, height: 64) } Image(systemName: icon) .font(.system(size: 21, weight: .semibold)) .foregroundStyle(isProminent ? Color.white : tint) } Text(label) .font(.system(size: 11, weight: .medium)) .foregroundStyle(tint) } .frame(width: 88) } .buttonStyle(.plain) .disabled(isEndingRecording) .opacity(isEndingRecording && !isProminent ? 0.4 : 1) .accessibilityLabel(label) } // MARK: - Note Input Sheet private var noteInputSheet: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() VStack(alignment: .leading, spacing: 16) { Label( "添加到 \(formattedNoteTime(pendingNoteTimeMs))", systemImage: "clock" ) .font(.system(size: 12, weight: .medium)) .foregroundStyle(Color.secondary) ZStack(alignment: .topLeading) { if noteText.isEmpty { Text("输入笔记内容…") .font(.system(size: 15)) .foregroundStyle(Color.secondary.opacity(0.6)) .padding(.horizontal, 18) .padding(.vertical, 16) .allowsHitTesting(false) } TextEditor(text: $noteText) .font(.system(size: 15)) .foregroundStyle(Color.primary) .scrollContentBackground(.hidden) .padding(10) .frame(minHeight: 160) .background(Color.cardBackground.opacity(0.5)) .businessBorder(cornerRadius: 8) } Spacer() } .padding(20) } .navigationTitle("添加笔记") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { showNoteSheet = false noteText = "" } } ToolbarItem(placement: .confirmationAction) { Button("保存") { addNote() } .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } .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) event.relativeTimeMs = Int64(pendingNoteTimeMs.rounded()) HapticManager.trigger(.noteAdded) showLatestEvent(event) noteText = "" showNoteSheet = false } private func presentNoteSheet() { pendingNoteTimeMs = max(0, recordingVM.elapsedTime * 1_000) noteText = "" showNoteSheet = true } private func formattedNoteTime(_ timeMs: Double) -> String { let totalSeconds = max(0, Int(timeMs / 1_000)) let hours = totalSeconds / 3_600 let minutes = (totalSeconds % 3_600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } private func toggleRecordingPause() { guard !isEndingRecording else { return } HapticManager.trigger(.tapFeedback) if recordingVM.isPaused { recordingVM.resumeRecording() } else { recordingVM.pauseRecording() } } private func endRecording() { guard !isEndingRecording else { return } commitRecordName() 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 RecordingLiveActivityManager.shared.end(elapsedSeconds: totalRecordedSeconds) hasStartedLiveActivity = false 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 "事件" } } private func commitRecordName() { let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines) if trimmedName.isEmpty { recordName = session.title return } guard session.title != trimmedName else { if recordName != trimmedName { recordName = trimmedName } return } recordName = trimmedName session.title = trimmedName session.isSynced = false session.syncState = .pending try? modelContext.save() } private func saveRecordNameAndCloseEditor() { commitRecordName() guard !recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } showRecordNameEditor = false } private func handleRecordingURL(_ url: URL) { guard url.scheme == "celestiatrace", url.host == "recording" else { return } let components = url.pathComponents.filter { $0 != "/" } guard let sessionID = components.first, sessionID.caseInsensitiveCompare(session.id.uuidString) == .orderedSame else { return } switch components.dropFirst().first { case "photo": capturePhoto() case "note": presentNoteSheet() default: break } } } struct RecordNameEditorSheet: View { @Binding var recordName: String let onSave: () -> Void let onCancel: () -> Void @FocusState private var isRecordNameFocused: Bool var body: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() VStack(alignment: .leading, spacing: 10) { Text("记录名称") .font(.system(size: 10, weight: .semibold, design: .monospaced)) .foregroundStyle(Color.secondary) .tracking(1.4) TextField("输入记录名称", text: $recordName) .focused($isRecordNameFocused) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(Color.primary) .textInputAutocapitalization(.never) .autocorrectionDisabled() .submitLabel(.done) .padding(.horizontal, 14) .padding(.vertical, 12) .background(Color.cardBackground.opacity(0.5)) .businessBorder(cornerRadius: 8) .onSubmit { onSave() } Spacer() } .padding(20) } .navigationTitle("修改记录名称") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消", action: onCancel) .foregroundStyle(Color.secondary) } ToolbarItem(placement: .confirmationAction) { Button("保存", action: onSave) .disabled(recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } .presentationDetents([.height(190)]) .presentationDragIndicator(.visible) .presentationBackground(Color.spaceBlack) .onAppear { DispatchQueue.main.async { isRecordNameFocused = true } } } } // 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) }