ActiveRecordingView.swift 30 KB

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