ActiveRecordingView.swift 26 KB

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