ActiveRecordingView.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  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. init(
  23. session: CelestiaSession,
  24. initialDuration: TimeInterval = 0,
  25. recordingSource: RecordingSourceChoice = .iPhone,
  26. onFinishRecording: (() -> Void)? = nil
  27. ) {
  28. self.session = session
  29. self.initialDuration = initialDuration
  30. self.recordingSource = recordingSource
  31. self.onFinishRecording = onFinishRecording
  32. _recordingVM = State(initialValue: RecordingViewModel(source: recordingSource))
  33. }
  34. var body: some View {
  35. ZStack {
  36. // Dynamic clean background
  37. Color.spaceBlack
  38. .ignoresSafeArea()
  39. VStack(spacing: 0) {
  40. Spacer()
  41. .frame(height: 20)
  42. // Top Header Block (Timecode + Indicator)
  43. VStack(spacing: 16) {
  44. timecodeSection
  45. recordingIndicator
  46. }
  47. .padding(.bottom, 24)
  48. // Live Waveform
  49. waveformSection
  50. .padding(.horizontal, 24)
  51. .padding(.bottom, 20)
  52. // Latest Event Card
  53. latestEventCard
  54. .padding(.horizontal, 24)
  55. .padding(.bottom, 20)
  56. Spacer()
  57. // Action Buttons
  58. actionButtons
  59. .padding(.horizontal, 48)
  60. .padding(.bottom, 32)
  61. // End Recording
  62. endRecordingButton
  63. .padding(.bottom, 24)
  64. }
  65. }
  66. .toolbar(.hidden, for: .tabBar)
  67. .toolbar(.hidden, for: .navigationBar)
  68. .navigationBarHidden(true)
  69. .navigationBarBackButtonHidden(true)
  70. .sheet(isPresented: $showNoteSheet) {
  71. noteInputSheet
  72. }
  73. .fullScreenCover(isPresented: $showCamera) {
  74. ImagePicker(sourceType: UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary) { image in
  75. saveImageAndAddEvent(image)
  76. }
  77. }
  78. .onAppear {
  79. UIApplication.shared.isIdleTimerDisabled = true
  80. recordingVM.startRecording(initialDuration: initialDuration)
  81. }
  82. .onDisappear {
  83. UIApplication.shared.isIdleTimerDisabled = false
  84. if recordingVM.isRecording {
  85. recordingVM.stopRecording()
  86. }
  87. }
  88. .alert("无法结束设备录音", isPresented: Binding(
  89. get: { stopErrorMessage != nil },
  90. set: { if !$0 { stopErrorMessage = nil } }
  91. )) {
  92. Button("重试") { endRecording() }
  93. Button("继续录音", role: .cancel) { stopErrorMessage = nil }
  94. } message: {
  95. Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。")
  96. }
  97. }
  98. // MARK: - Timecode
  99. private var timecodeSection: some View {
  100. Text(recordingVM.elapsedTimeFormatted)
  101. .font(.system(size: 52, weight: .light))
  102. .monospacedDigit()
  103. .foregroundStyle(Color.primary)
  104. .contentTransition(.numericText(countsDown: false))
  105. .frame(height: 64)
  106. }
  107. // MARK: - Recording Indicator
  108. private var recordingIndicator: some View {
  109. VStack(spacing: 8) {
  110. HStack(spacing: 8) {
  111. TimelineView(.periodic(from: .now, by: 1.0)) { context in
  112. let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0
  113. Circle()
  114. .fill(recordingVM.isRecording ? Color.recordingRed : Color.secondary)
  115. .frame(width: 8, height: 8)
  116. .opacity(recordingVM.isRecording ? (isEven ? 1.0 : 0.3) : 0.6)
  117. .animation(.easeInOut(duration: 0.5), value: isEven)
  118. }
  119. .frame(width: 10, height: 10)
  120. Text(recordingVM.isPaused ? "已暂停" : recordingVM.statusMessage)
  121. .font(.system(size: 11, weight: .semibold, design: .monospaced))
  122. .foregroundStyle(recordingVM.isRecording ? Color.recordingRed : Color.secondary)
  123. .tracking(1.2)
  124. Text("· \(session.title)")
  125. .font(.system(size: 11, weight: .regular))
  126. .foregroundStyle(Color.secondary.opacity(0.7))
  127. .lineLimit(1)
  128. }
  129. HStack(spacing: 6) {
  130. Image(systemName: recordingSource.systemImage)
  131. .font(.system(size: 11, weight: .medium))
  132. Text("当前录音设备:\(recordingVM.sourceDisplayName)")
  133. .font(.system(size: 11, weight: .medium))
  134. }
  135. .foregroundStyle(Color.primary.opacity(0.85))
  136. if let error = recordingVM.errorMessage {
  137. Text(error)
  138. .font(.system(size: 10))
  139. .foregroundStyle(Color.recordingRed)
  140. .multilineTextAlignment(.center)
  141. .lineLimit(2)
  142. }
  143. }
  144. .padding(.horizontal, 10)
  145. .padding(.vertical, 8)
  146. .businessBorder(cornerRadius: 6)
  147. }
  148. // MARK: - Waveform
  149. private var waveformSection: some View {
  150. VStack(spacing: 0) {
  151. LiveWaveformView(samples: recordingVM.waveformSamples)
  152. .frame(height: 90)
  153. // Graphical VU Meter + Numeric Display
  154. HStack(spacing: 10) {
  155. Text("音量电平")
  156. .font(.system(size: 9, weight: .semibold, design: .monospaced))
  157. .foregroundStyle(Color.secondary.opacity(0.6))
  158. .tracking(1.5)
  159. AmplitudeLevelMeterView(amplitude: recordingVM.currentAmplitude)
  160. Spacer(minLength: 0)
  161. Text(String(format: "%02.0f%%", recordingVM.currentAmplitude * 100))
  162. .font(.system(size: 9, weight: .medium, design: .monospaced))
  163. .foregroundStyle(recordingVM.currentAmplitude > 0.85 ? Color.recordingRed : Color.secondary)
  164. }
  165. .padding(.horizontal, 4)
  166. .padding(.top, 10)
  167. }
  168. .frame(height: 114)
  169. }
  170. // MARK: - Latest Event Card
  171. private var latestEventCard: some View {
  172. ZStack { // Fix: Use ZStack instead of Group to enforce the fixed frame even when empty
  173. if let event = latestEvent, latestEventVisible {
  174. HStack(spacing: 12) {
  175. Image(systemName: event.eventIcon)
  176. .font(.system(size: 12))
  177. .foregroundStyle(Color.primary)
  178. .frame(width: 28, height: 28)
  179. .businessBorder(cornerRadius: 14)
  180. VStack(alignment: .leading, spacing: 2) {
  181. Text(eventLabel(for: event.eventType))
  182. .font(.system(size: 11, weight: .semibold))
  183. .foregroundStyle(Color.primary)
  184. if let text = event.textContent {
  185. Text(text)
  186. .font(.system(size: 12))
  187. .foregroundStyle(Color.secondary)
  188. .lineLimit(1)
  189. }
  190. }
  191. Spacer()
  192. Text(event.relativeTimeFormatted)
  193. .font(.system(size: 11, weight: .regular, design: .monospaced))
  194. .foregroundStyle(Color.secondary)
  195. }
  196. .padding(12)
  197. .background(Color.cardBackground.opacity(0.4))
  198. .businessBorder(cornerRadius: 8)
  199. .transition(.asymmetric(
  200. insertion: .move(edge: .bottom).combined(with: .opacity),
  201. removal: .opacity
  202. ))
  203. }
  204. }
  205. .frame(height: 56)
  206. .animation(.spring(response: 0.35, dampingFraction: 0.8), value: latestEvent?.id)
  207. }
  208. // MARK: - Action Buttons
  209. private var actionButtons: some View {
  210. HStack(spacing: 40) {
  211. // Camera button
  212. actionButton(
  213. icon: "camera",
  214. label: "拍照打点"
  215. ) {
  216. capturePhoto()
  217. }
  218. // Note button
  219. actionButton(
  220. icon: "doc.text",
  221. label: "文字笔记"
  222. ) {
  223. showNoteSheet = true
  224. }
  225. }
  226. }
  227. private func actionButton(
  228. icon: String,
  229. label: String,
  230. action: @escaping () -> Void
  231. ) -> some View {
  232. Button(action: action) {
  233. VStack(spacing: 10) {
  234. ZStack {
  235. Circle()
  236. .fill(Color.primary.opacity(0.02))
  237. .frame(width: 60, height: 60)
  238. .businessBorder(cornerRadius: 30)
  239. Image(systemName: icon)
  240. .font(.system(size: 20, weight: .light))
  241. .foregroundStyle(Color.primary)
  242. }
  243. Text(label)
  244. .font(.system(size: 11, weight: .regular))
  245. .foregroundStyle(Color.secondary)
  246. }
  247. }
  248. .buttonStyle(.plain)
  249. }
  250. // MARK: - End Recording Button
  251. private var endRecordingButton: some View {
  252. Button {
  253. endRecording()
  254. } label: {
  255. VStack(spacing: 6) {
  256. Text("双击结束录制")
  257. .font(.system(size: 13, weight: .medium))
  258. .foregroundStyle(Color.recordingRed)
  259. .padding(.horizontal, 24)
  260. .padding(.vertical, 10)
  261. .businessBorder(cornerRadius: 8)
  262. Text("双击即可完成本次现场录音")
  263. .font(.system(size: 8, weight: .medium, design: .monospaced))
  264. .foregroundStyle(Color.secondary.opacity(0.5))
  265. .tracking(1.5)
  266. }
  267. }
  268. .highPriorityGesture(
  269. TapGesture(count: 2).onEnded {
  270. endRecording()
  271. }
  272. )
  273. .buttonStyle(.plain)
  274. .disabled(isEndingRecording)
  275. }
  276. // MARK: - Note Input Sheet
  277. private var noteInputSheet: some View {
  278. NavigationStack {
  279. ZStack {
  280. Color.spaceBlack.ignoresSafeArea()
  281. VStack(spacing: 20) {
  282. TextField("输入笔记内容...", text: $noteText, axis: .vertical)
  283. .textFieldStyle(.plain)
  284. .font(.system(size: 15))
  285. .foregroundStyle(Color.primary)
  286. .padding(14)
  287. .frame(minHeight: 120, alignment: .top)
  288. .background(Color.cardBackground.opacity(0.5))
  289. .businessBorder(cornerRadius: 8)
  290. Button {
  291. addNote()
  292. } label: {
  293. Text("保存笔记")
  294. .font(.system(size: 14, weight: .semibold))
  295. .foregroundStyle(Color.spaceBlack)
  296. .frame(maxWidth: .infinity)
  297. .padding(.vertical, 12)
  298. .background(Color.primary)
  299. .cornerRadius(8)
  300. }
  301. .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  302. Spacer()
  303. }
  304. .padding(24)
  305. }
  306. .navigationTitle("添加笔记")
  307. .navigationBarTitleDisplayMode(.inline)
  308. .toolbar {
  309. ToolbarItem(placement: .cancellationAction) {
  310. Button("取消") {
  311. showNoteSheet = false
  312. noteText = ""
  313. }
  314. .foregroundStyle(Color.secondary)
  315. }
  316. }
  317. }
  318. .presentationDetents([.medium])
  319. .presentationDragIndicator(.visible)
  320. .presentationBackground(Color.spaceBlack)
  321. }
  322. // MARK: - Actions
  323. private func capturePhoto() {
  324. showCamera = true
  325. }
  326. private func saveImageAndAddEvent(_ image: UIImage) {
  327. guard let data = image.jpegData(compressionQuality: 0.8) else { return }
  328. let filename = "photo_\(UUID().uuidString).jpg"
  329. let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  330. let fileURL = documentsURL.appendingPathComponent(filename)
  331. do {
  332. try data.write(to: fileURL)
  333. let event = recordingVM.addPhotoEvent(to: session, localFilePath: filename)
  334. HapticManager.trigger(.photoCapture)
  335. showLatestEvent(event)
  336. } catch {
  337. print("[ActiveRecordingView] Failed to save captured photo: \(error.localizedDescription)")
  338. }
  339. }
  340. private func addNote() {
  341. let text = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
  342. guard !text.isEmpty else { return }
  343. let event = recordingVM.addNoteEvent(to: session, text: text)
  344. HapticManager.trigger(.noteAdded)
  345. showLatestEvent(event)
  346. noteText = ""
  347. showNoteSheet = false
  348. }
  349. private func endRecording() {
  350. guard !isEndingRecording else { return }
  351. isEndingRecording = true
  352. recordingVM.stopRecording { result in
  353. switch result {
  354. case .success(let confirmedURL):
  355. finalizeRecording(newRecordedURL: confirmedURL ?? recordingVM.outputFileURL)
  356. case .failure(let error):
  357. isEndingRecording = false
  358. stopErrorMessage = error.localizedDescription
  359. }
  360. }
  361. }
  362. private func finalizeRecording(newRecordedURL: URL?) {
  363. let existingPath = session.localAudioPath
  364. let existingURL = AudioPathHelper.resolveURL(for: existingPath)
  365. let totalRecordedSeconds = recordingVM.elapsedTime
  366. if let newRecordedURL = newRecordedURL {
  367. Task { @MainActor in
  368. let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL)
  369. session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path)
  370. session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
  371. try? modelContext.save()
  372. HapticManager.trigger(.recordStop)
  373. isEndingRecording = false
  374. dismiss()
  375. onFinishRecording?()
  376. }
  377. } else {
  378. if existingURL != nil {
  379. session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
  380. try? modelContext.save()
  381. } else {
  382. session.endTime = Date()
  383. try? modelContext.save()
  384. }
  385. HapticManager.trigger(.recordStop)
  386. isEndingRecording = false
  387. dismiss()
  388. onFinishRecording?()
  389. }
  390. }
  391. private func showLatestEvent(_ event: CelestiaTimelineEvent) {
  392. withAnimation {
  393. latestEvent = event
  394. latestEventVisible = true
  395. }
  396. // Auto-dismiss after a few seconds
  397. DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
  398. withAnimation {
  399. latestEventVisible = false
  400. }
  401. }
  402. }
  403. // MARK: - Helpers
  404. private func eventLabel(for type: String) -> String {
  405. switch type {
  406. case "PHOTO": return "已捕获照片"
  407. case "NOTE": return "已保存笔记"
  408. case "MARKER": return "标记点"
  409. case "VOICE": return "音轨事件"
  410. default: return "事件"
  411. }
  412. }
  413. }
  414. // MARK: - Amplitude Level Meter View
  415. /// A minimalist graphical VU meter bar visualizing real-time audio amplitude.
  416. private struct AmplitudeLevelMeterView: View {
  417. let amplitude: Float // 0.0 to 1.0
  418. private let totalSegments: Int = 16
  419. var body: some View {
  420. HStack(spacing: 3) {
  421. ForEach(0..<totalSegments, id: \.self) { index in
  422. let threshold = Float(index) / Float(totalSegments)
  423. let isFilled = amplitude > threshold
  424. let isPeak = index >= totalSegments - 2
  425. RoundedRectangle(cornerRadius: 1)
  426. .fill(
  427. isFilled
  428. ? (isPeak ? Color.recordingRed : Color.primary.opacity(0.85))
  429. : Color.primary.opacity(0.12)
  430. )
  431. .frame(height: isFilled ? (isPeak ? 7 : 5) : 3)
  432. .animation(.spring(response: 0.15, dampingFraction: 0.75), value: amplitude)
  433. }
  434. }
  435. }
  436. }
  437. // MARK: - Preview
  438. #Preview {
  439. let session = CelestiaSession(title: "Preview Session")
  440. ActiveRecordingView(session: session)
  441. .modelContainer(for: CelestiaSession.self, inMemory: true)
  442. }