ActiveRecordingView.swift 14 KB

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