ActiveRecordingView.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. import SwiftUI
  2. import SwiftData
  3. // MARK: - ActiveRecordingView
  4. /// The live recording screen showing real-time waveform, elapsed time,
  5. /// and quick-action buttons for adding photos and notes during a session.
  6. /// Fully redesigned to use a minimalist business-focused line-drawn UI.
  7. struct ActiveRecordingView: View {
  8. @Environment(\.modelContext) private var modelContext
  9. @Environment(\.dismiss) private var dismiss
  10. let session: CelestiaSession
  11. var initialDuration: TimeInterval = 0
  12. let recordingSource: RecordingSourceChoice
  13. var onFinishRecording: (() -> Void)? = nil
  14. @State private var recordingVM: RecordingViewModel
  15. @State private var showNoteSheet = false
  16. @State private var showCamera = false
  17. @State private var noteText = ""
  18. @State private var latestEvent: CelestiaTimelineEvent?
  19. @State private var latestEventVisible = false
  20. @State private var isEndingRecording = false
  21. @State private var stopErrorMessage: String?
  22. @State private var recordName: String
  23. @State private var showRecordNameEditor = false
  24. @FocusState private var isRecordNameFocused: Bool
  25. init(
  26. session: CelestiaSession,
  27. initialDuration: TimeInterval = 0,
  28. recordingSource: RecordingSourceChoice = .iPhone,
  29. onFinishRecording: (() -> Void)? = nil
  30. ) {
  31. self.session = session
  32. self.initialDuration = initialDuration
  33. self.recordingSource = recordingSource
  34. self.onFinishRecording = onFinishRecording
  35. _recordingVM = State(initialValue: RecordingViewModel(source: recordingSource))
  36. _recordName = State(initialValue: session.title)
  37. }
  38. var body: some View {
  39. ZStack(alignment: .top) {
  40. // Dynamic clean background
  41. Color.spaceBlack
  42. .ignoresSafeArea()
  43. .contentShape(Rectangle())
  44. .onTapGesture(count: 2) {
  45. endRecording()
  46. }
  47. VStack(spacing: 0) {
  48. recordNameSection
  49. .padding(.horizontal, 24)
  50. .padding(.top, 16)
  51. .padding(.bottom, 14)
  52. // Top Header Block (Timecode + Indicator)
  53. VStack(spacing: 16) {
  54. timecodeSection
  55. recordingIndicator
  56. }
  57. .padding(.bottom, 24)
  58. // Live Waveform
  59. waveformSection
  60. .padding(.horizontal, 24)
  61. .padding(.bottom, 20)
  62. // Latest Event Card
  63. latestEventCard
  64. .padding(.horizontal, 24)
  65. .padding(.bottom, 20)
  66. Spacer()
  67. // Action Buttons
  68. actionButtons
  69. .padding(.horizontal, 48)
  70. .padding(.bottom, 32)
  71. // Recording Controls
  72. recordingControlButtons
  73. .padding(.bottom, 24)
  74. }
  75. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
  76. .ignoresSafeArea(.keyboard, edges: .bottom)
  77. }
  78. .ignoresSafeArea(.keyboard, edges: .bottom)
  79. .toolbar(.hidden, for: .tabBar)
  80. .toolbar(.hidden, for: .navigationBar)
  81. .navigationBarHidden(true)
  82. .navigationBarBackButtonHidden(true)
  83. .sheet(isPresented: $showRecordNameEditor) {
  84. recordNameEditorSheet
  85. }
  86. .sheet(isPresented: $showNoteSheet) {
  87. noteInputSheet
  88. }
  89. .fullScreenCover(isPresented: $showCamera) {
  90. ImagePicker(sourceType: UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary) { image in
  91. saveImageAndAddEvent(image)
  92. }
  93. }
  94. .onAppear {
  95. UIApplication.shared.isIdleTimerDisabled = true
  96. recordingVM.startRecording(initialDuration: initialDuration)
  97. }
  98. .onDisappear {
  99. UIApplication.shared.isIdleTimerDisabled = false
  100. commitRecordName()
  101. if recordingVM.isRecording {
  102. recordingVM.stopRecording()
  103. }
  104. }
  105. .alert("无法结束设备录音", isPresented: Binding(
  106. get: { stopErrorMessage != nil },
  107. set: { if !$0 { stopErrorMessage = nil } }
  108. )) {
  109. Button("重试") { endRecording() }
  110. Button("继续录音", role: .cancel) { stopErrorMessage = nil }
  111. } message: {
  112. Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。")
  113. }
  114. }
  115. // MARK: - Record Name
  116. private var recordNameSection: some View {
  117. Button {
  118. recordName = session.title
  119. showRecordNameEditor = true
  120. } label: {
  121. VStack(alignment: .leading, spacing: 8) {
  122. HStack(spacing: 6) {
  123. Text("记录名称")
  124. .font(.system(size: 10, weight: .semibold, design: .monospaced))
  125. .tracking(1.4)
  126. Spacer()
  127. Image(systemName: "pencil")
  128. .font(.system(size: 11, weight: .semibold))
  129. }
  130. .foregroundStyle(Color.secondary)
  131. Text(session.title)
  132. .font(.system(size: 19, weight: .semibold))
  133. .foregroundStyle(Color.primary)
  134. .lineLimit(1)
  135. .frame(maxWidth: .infinity, alignment: .leading)
  136. Rectangle()
  137. .fill(Color.primary.opacity(0.16))
  138. .frame(height: 1)
  139. }
  140. .padding(.horizontal, 14)
  141. .padding(.vertical, 12)
  142. .background(Color.cardBackground.opacity(0.45))
  143. .businessBorder(cornerRadius: 8)
  144. .contentShape(Rectangle())
  145. }
  146. .buttonStyle(.plain)
  147. }
  148. private var recordNameEditorSheet: some View {
  149. NavigationStack {
  150. ZStack {
  151. Color.spaceBlack.ignoresSafeArea()
  152. VStack(alignment: .leading, spacing: 10) {
  153. Text("记录名称")
  154. .font(.system(size: 10, weight: .semibold, design: .monospaced))
  155. .foregroundStyle(Color.secondary)
  156. .tracking(1.4)
  157. TextField("输入记录名称", text: $recordName)
  158. .focused($isRecordNameFocused)
  159. .font(.system(size: 18, weight: .semibold))
  160. .foregroundStyle(Color.primary)
  161. .textInputAutocapitalization(.never)
  162. .autocorrectionDisabled()
  163. .submitLabel(.done)
  164. .padding(.horizontal, 14)
  165. .padding(.vertical, 12)
  166. .background(Color.cardBackground.opacity(0.5))
  167. .businessBorder(cornerRadius: 8)
  168. .onSubmit {
  169. saveRecordNameAndCloseEditor()
  170. }
  171. Spacer()
  172. }
  173. .padding(20)
  174. }
  175. .navigationTitle("修改记录名称")
  176. .navigationBarTitleDisplayMode(.inline)
  177. .toolbar {
  178. ToolbarItem(placement: .cancellationAction) {
  179. Button("取消") {
  180. recordName = session.title
  181. showRecordNameEditor = false
  182. }
  183. .foregroundStyle(Color.secondary)
  184. }
  185. ToolbarItem(placement: .confirmationAction) {
  186. Button("保存") {
  187. saveRecordNameAndCloseEditor()
  188. }
  189. .disabled(recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  190. }
  191. }
  192. }
  193. .presentationDetents([.height(190)])
  194. .presentationDragIndicator(.visible)
  195. .presentationBackground(Color.spaceBlack)
  196. .onAppear {
  197. DispatchQueue.main.async {
  198. isRecordNameFocused = true
  199. }
  200. }
  201. }
  202. // MARK: - Timecode
  203. private var timecodeSection: some View {
  204. Text(recordingVM.elapsedTimeFormatted)
  205. .font(.system(size: 52, weight: .light))
  206. .monospacedDigit()
  207. .foregroundStyle(Color.primary)
  208. .contentTransition(.numericText(countsDown: false))
  209. .frame(height: 64)
  210. }
  211. // MARK: - Recording Indicator
  212. private var recordingIndicator: some View {
  213. VStack(spacing: 8) {
  214. HStack(spacing: 8) {
  215. TimelineView(.periodic(from: .now, by: 1.0)) { context in
  216. let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0
  217. Circle()
  218. .fill(recordingVM.isRecording && !recordingVM.isPaused ? Color.recordingRed : Color.secondary)
  219. .frame(width: 8, height: 8)
  220. .opacity(recordingVM.isRecording && !recordingVM.isPaused ? (isEven ? 1.0 : 0.3) : 0.6)
  221. .animation(.easeInOut(duration: 0.5), value: isEven)
  222. }
  223. .frame(width: 10, height: 10)
  224. Text(recordingVM.isPaused ? "已暂停" : recordingVM.statusMessage)
  225. .font(.system(size: 11, weight: .semibold, design: .monospaced))
  226. .foregroundStyle(recordingVM.isRecording && !recordingVM.isPaused ? Color.recordingRed : Color.secondary)
  227. .tracking(1.2)
  228. }
  229. HStack(spacing: 6) {
  230. Image(systemName: recordingSource.systemImage)
  231. .font(.system(size: 11, weight: .medium))
  232. Text("设备:\(recordingVM.sourceDisplayName)")
  233. .font(.system(size: 11, weight: .medium))
  234. }
  235. .foregroundStyle(Color.primary.opacity(0.85))
  236. if let error = recordingVM.errorMessage {
  237. Text(error)
  238. .font(.system(size: 10))
  239. .foregroundStyle(Color.recordingRed)
  240. .multilineTextAlignment(.center)
  241. .lineLimit(2)
  242. }
  243. }
  244. .padding(.horizontal, 10)
  245. .padding(.vertical, 8)
  246. .businessBorder(cornerRadius: 6)
  247. }
  248. // MARK: - Waveform
  249. private var waveformSection: some View {
  250. VStack(spacing: 0) {
  251. LiveWaveformView(samples: recordingVM.waveformSamples)
  252. .frame(height: 90)
  253. // Graphical VU Meter + Numeric Display
  254. HStack(spacing: 10) {
  255. AmplitudeLevelMeterView(amplitude: recordingVM.currentAmplitude)
  256. Spacer(minLength: 0)
  257. Text(String(format: "%02.0f%%", recordingVM.currentAmplitude * 100))
  258. .font(.system(size: 9, weight: .medium, design: .monospaced))
  259. .foregroundStyle(recordingVM.currentAmplitude > 0.85 ? Color.recordingRed : Color.secondary)
  260. }
  261. .padding(.horizontal, 4)
  262. .padding(.top, 10)
  263. }
  264. .frame(height: 114)
  265. }
  266. // MARK: - Latest Event Card
  267. private var latestEventCard: some View {
  268. ZStack { // Fix: Use ZStack instead of Group to enforce the fixed frame even when empty
  269. if let event = latestEvent, latestEventVisible {
  270. HStack(spacing: 12) {
  271. Image(systemName: event.eventIcon)
  272. .font(.system(size: 12))
  273. .foregroundStyle(Color.primary)
  274. .frame(width: 28, height: 28)
  275. .businessBorder(cornerRadius: 14)
  276. VStack(alignment: .leading, spacing: 2) {
  277. Text(eventLabel(for: event.eventType))
  278. .font(.system(size: 11, weight: .semibold))
  279. .foregroundStyle(Color.primary)
  280. if let text = event.textContent {
  281. Text(text)
  282. .font(.system(size: 12))
  283. .foregroundStyle(Color.secondary)
  284. .lineLimit(1)
  285. }
  286. }
  287. Spacer()
  288. Text(event.relativeTimeFormatted)
  289. .font(.system(size: 11, weight: .regular, design: .monospaced))
  290. .foregroundStyle(Color.secondary)
  291. }
  292. .padding(12)
  293. .background(Color.cardBackground.opacity(0.4))
  294. .businessBorder(cornerRadius: 8)
  295. .transition(.asymmetric(
  296. insertion: .move(edge: .bottom).combined(with: .opacity),
  297. removal: .opacity
  298. ))
  299. }
  300. }
  301. .frame(height: 56)
  302. .animation(.spring(response: 0.35, dampingFraction: 0.8), value: latestEvent?.id)
  303. }
  304. // MARK: - Action Buttons
  305. private var actionButtons: some View {
  306. HStack(spacing: 40) {
  307. // Camera button
  308. actionButton(
  309. icon: "camera",
  310. label: "拍照打点"
  311. ) {
  312. capturePhoto()
  313. }
  314. // Note button
  315. actionButton(
  316. icon: "doc.text",
  317. label: "文字笔记"
  318. ) {
  319. showNoteSheet = true
  320. }
  321. }
  322. }
  323. private func actionButton(
  324. icon: String,
  325. label: String,
  326. action: @escaping () -> Void
  327. ) -> some View {
  328. Button(action: action) {
  329. VStack(spacing: 10) {
  330. ZStack {
  331. Circle()
  332. .fill(Color.primary.opacity(0.02))
  333. .frame(width: 60, height: 60)
  334. .businessBorder(cornerRadius: 30)
  335. Image(systemName: icon)
  336. .font(.system(size: 20, weight: .light))
  337. .foregroundStyle(Color.primary)
  338. }
  339. Text(label)
  340. .font(.system(size: 11, weight: .regular))
  341. .foregroundStyle(Color.secondary)
  342. }
  343. }
  344. .buttonStyle(.plain)
  345. }
  346. // MARK: - Recording Controls
  347. private var recordingControlButtons: some View {
  348. HStack(spacing: 52) {
  349. recordingControlButton(
  350. icon: recordingVM.isPaused ? "play.fill" : "pause.fill",
  351. label: recordingVM.isPaused ? "继续记录" : "暂停",
  352. tint: .primary
  353. ) {
  354. toggleRecordingPause()
  355. }
  356. recordingControlButton(
  357. icon: "stop.fill",
  358. label: isEndingRecording ? "正在结束" : "结束记录",
  359. tint: .recordingRed,
  360. isProminent: true
  361. ) {
  362. endRecording()
  363. }
  364. }
  365. }
  366. private func recordingControlButton(
  367. icon: String,
  368. label: String,
  369. tint: Color,
  370. isProminent: Bool = false,
  371. action: @escaping () -> Void
  372. ) -> some View {
  373. Button(action: action) {
  374. VStack(spacing: 9) {
  375. ZStack {
  376. Circle()
  377. .fill(isProminent ? tint : Color.primary.opacity(0.02))
  378. .frame(width: 64, height: 64)
  379. if !isProminent {
  380. Circle()
  381. .stroke(Color.primary.opacity(0.18), lineWidth: 1)
  382. .frame(width: 64, height: 64)
  383. }
  384. Image(systemName: icon)
  385. .font(.system(size: 21, weight: .semibold))
  386. .foregroundStyle(isProminent ? Color.white : tint)
  387. }
  388. Text(label)
  389. .font(.system(size: 11, weight: .medium))
  390. .foregroundStyle(tint)
  391. }
  392. .frame(width: 88)
  393. }
  394. .buttonStyle(.plain)
  395. .disabled(isEndingRecording)
  396. .opacity(isEndingRecording && !isProminent ? 0.4 : 1)
  397. .accessibilityLabel(label)
  398. }
  399. // MARK: - Note Input Sheet
  400. private var noteInputSheet: some View {
  401. NavigationStack {
  402. ZStack {
  403. Color.spaceBlack.ignoresSafeArea()
  404. VStack(spacing: 20) {
  405. TextField("输入笔记内容...", text: $noteText, axis: .vertical)
  406. .textFieldStyle(.plain)
  407. .font(.system(size: 15))
  408. .foregroundStyle(Color.primary)
  409. .padding(14)
  410. .frame(minHeight: 120, alignment: .top)
  411. .background(Color.cardBackground.opacity(0.5))
  412. .businessBorder(cornerRadius: 8)
  413. Button {
  414. addNote()
  415. } label: {
  416. Text("保存笔记")
  417. .font(.system(size: 14, weight: .semibold))
  418. .foregroundStyle(Color.spaceBlack)
  419. .frame(maxWidth: .infinity)
  420. .padding(.vertical, 12)
  421. .background(Color.primary)
  422. .cornerRadius(8)
  423. }
  424. .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  425. Spacer()
  426. }
  427. .padding(24)
  428. }
  429. .navigationTitle("添加笔记")
  430. .navigationBarTitleDisplayMode(.inline)
  431. .toolbar {
  432. ToolbarItem(placement: .cancellationAction) {
  433. Button("取消") {
  434. showNoteSheet = false
  435. noteText = ""
  436. }
  437. .foregroundStyle(Color.secondary)
  438. }
  439. }
  440. }
  441. .presentationDetents([.medium])
  442. .presentationDragIndicator(.visible)
  443. .presentationBackground(Color.spaceBlack)
  444. }
  445. // MARK: - Actions
  446. private func capturePhoto() {
  447. showCamera = true
  448. }
  449. private func saveImageAndAddEvent(_ image: UIImage) {
  450. guard let data = image.jpegData(compressionQuality: 0.8) else { return }
  451. let filename = "photo_\(UUID().uuidString).jpg"
  452. let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  453. let fileURL = documentsURL.appendingPathComponent(filename)
  454. do {
  455. try data.write(to: fileURL)
  456. let event = recordingVM.addPhotoEvent(to: session, localFilePath: filename)
  457. HapticManager.trigger(.photoCapture)
  458. showLatestEvent(event)
  459. } catch {
  460. print("[ActiveRecordingView] Failed to save captured photo: \(error.localizedDescription)")
  461. }
  462. }
  463. private func addNote() {
  464. let text = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
  465. guard !text.isEmpty else { return }
  466. let event = recordingVM.addNoteEvent(to: session, text: text)
  467. HapticManager.trigger(.noteAdded)
  468. showLatestEvent(event)
  469. noteText = ""
  470. showNoteSheet = false
  471. }
  472. private func toggleRecordingPause() {
  473. guard !isEndingRecording else { return }
  474. HapticManager.trigger(.tapFeedback)
  475. if recordingVM.isPaused {
  476. recordingVM.resumeRecording()
  477. } else {
  478. recordingVM.pauseRecording()
  479. }
  480. }
  481. private func endRecording() {
  482. guard !isEndingRecording else { return }
  483. commitRecordName()
  484. isEndingRecording = true
  485. recordingVM.stopRecording { result in
  486. switch result {
  487. case .success(let confirmedURL):
  488. finalizeRecording(newRecordedURL: confirmedURL ?? recordingVM.outputFileURL)
  489. case .failure(let error):
  490. isEndingRecording = false
  491. stopErrorMessage = error.localizedDescription
  492. }
  493. }
  494. }
  495. private func finalizeRecording(newRecordedURL: URL?) {
  496. let existingPath = session.localAudioPath
  497. let existingURL = AudioPathHelper.resolveURL(for: existingPath)
  498. let totalRecordedSeconds = recordingVM.elapsedTime
  499. if let newRecordedURL = newRecordedURL {
  500. Task { @MainActor in
  501. let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL)
  502. session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path)
  503. session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
  504. try? modelContext.save()
  505. HapticManager.trigger(.recordStop)
  506. isEndingRecording = false
  507. dismiss()
  508. onFinishRecording?()
  509. }
  510. } else {
  511. if existingURL != nil {
  512. session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
  513. try? modelContext.save()
  514. } else {
  515. session.endTime = Date()
  516. try? modelContext.save()
  517. }
  518. HapticManager.trigger(.recordStop)
  519. isEndingRecording = false
  520. dismiss()
  521. onFinishRecording?()
  522. }
  523. }
  524. private func showLatestEvent(_ event: CelestiaTimelineEvent) {
  525. withAnimation {
  526. latestEvent = event
  527. latestEventVisible = true
  528. }
  529. // Auto-dismiss after a few seconds
  530. DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
  531. withAnimation {
  532. latestEventVisible = false
  533. }
  534. }
  535. }
  536. // MARK: - Helpers
  537. private func eventLabel(for type: String) -> String {
  538. switch type {
  539. case "PHOTO": return "已捕获照片"
  540. case "NOTE": return "已保存笔记"
  541. case "MARKER": return "标记点"
  542. case "VOICE": return "音轨事件"
  543. default: return "事件"
  544. }
  545. }
  546. private func commitRecordName() {
  547. let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
  548. if trimmedName.isEmpty {
  549. recordName = session.title
  550. return
  551. }
  552. guard session.title != trimmedName else {
  553. if recordName != trimmedName {
  554. recordName = trimmedName
  555. }
  556. return
  557. }
  558. recordName = trimmedName
  559. session.title = trimmedName
  560. session.isSynced = false
  561. session.syncState = .pending
  562. try? modelContext.save()
  563. }
  564. private func saveRecordNameAndCloseEditor() {
  565. commitRecordName()
  566. guard !recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
  567. isRecordNameFocused = false
  568. showRecordNameEditor = false
  569. }
  570. }
  571. // MARK: - Amplitude Level Meter View
  572. /// A minimalist graphical VU meter bar visualizing real-time audio amplitude.
  573. private struct AmplitudeLevelMeterView: View {
  574. let amplitude: Float // 0.0 to 1.0
  575. private let totalSegments: Int = 16
  576. var body: some View {
  577. HStack(spacing: 3) {
  578. ForEach(0..<totalSegments, id: \.self) { index in
  579. let threshold = Float(index) / Float(totalSegments)
  580. let isFilled = amplitude > threshold
  581. let isPeak = index >= totalSegments - 2
  582. RoundedRectangle(cornerRadius: 1)
  583. .fill(
  584. isFilled
  585. ? (isPeak ? Color.recordingRed : Color.primary.opacity(0.85))
  586. : Color.primary.opacity(0.12)
  587. )
  588. .frame(height: isFilled ? (isPeak ? 7 : 5) : 3)
  589. .animation(.spring(response: 0.15, dampingFraction: 0.75), value: amplitude)
  590. }
  591. }
  592. }
  593. }
  594. // MARK: - Preview
  595. #Preview {
  596. let session = CelestiaSession(title: "Preview Session")
  597. ActiveRecordingView(session: session)
  598. .modelContainer(for: CelestiaSession.self, inMemory: true)
  599. }