ActiveRecordingView.swift 15 KB

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