ActiveRecordingView.swift 28 KB

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