ActiveRecordingView.swift 33 KB

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