| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772 |
- 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..<totalSegments, id: \.self) { index in
- let threshold = Float(index) / Float(totalSegments)
- let isFilled = amplitude > 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)
- }
|