ActiveRecordingView.swift 27 KB

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