ActiveRecordingView.swift 28 KB

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