| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959 |
- import SwiftUI
- import SwiftData
- import UIKit
- // MARK: - ActiveRecordingView
- /// The live recording screen showing real-time waveform, elapsed time,
- /// and quick-action buttons for adding photos and notes during a session.
- /// Fully redesigned to use a minimalist business-focused line-drawn UI.
- struct ActiveRecordingView: View {
- @Environment(\.modelContext) private var modelContext
- @Environment(\.dismiss) private var dismiss
- @ObservedObject private var syncManager: SyncManager = .shared
- let session: CelestiaSession
- var initialDuration: TimeInterval = 0
- let recordingSource: RecordingSourceChoice
- var initialAction: RecordingLiveActivityAction? = nil
- var onFinishRecording: (() -> Void)? = nil
- @State private var recordingVM: RecordingViewModel
- @State private var showNoteSheet = false
- @State private var showPhotoEditor = false
- @State private var noteText = ""
- @State private var noteLocation: TimelineLocation?
- @State private var pendingNoteTimeMs: Double = 0
- @State private var pendingPhotoTimeMs: Double = 0
- @State private var latestEvent: CelestiaTimelineEvent?
- @State private var latestEventVisible = false
- @State private var isEndingRecording = false
- @State private var stopErrorMessage: String?
- @State private var photoSaveError: String?
- @State private var recordName: String
- @State private var showRecordNameEditor = false
- @State private var hasStartedLiveActivity = false
- @State private var hasAddedContinuationMarker = false
- @State private var pendingInitialAction: RecordingLiveActivityAction?
- init(
- session: CelestiaSession,
- initialDuration: TimeInterval = 0,
- recordingSource: RecordingSourceChoice = .iPhone,
- initialAction: RecordingLiveActivityAction? = nil,
- onFinishRecording: (() -> Void)? = nil
- ) {
- self.session = session
- self.initialDuration = initialDuration
- self.recordingSource = recordingSource
- self.initialAction = initialAction
- self.onFinishRecording = onFinishRecording
- _recordingVM = State(initialValue: RecordingViewModel(source: recordingSource))
- _recordName = State(initialValue: session.title)
- _pendingInitialAction = State(initialValue: initialAction)
- }
- var body: some View {
- ZStack(alignment: .top) {
- // Dynamic clean background
- Color.spaceBlack
- .ignoresSafeArea()
- .contentShape(Rectangle())
- .onTapGesture(count: 2) {
- endRecording()
- }
- VStack(spacing: 0) {
- recordNameSection
- .padding(.horizontal, 24)
- .padding(.top, 16)
- .padding(.bottom, 14)
- // Top Header Block (Timecode + Indicator)
- VStack(spacing: 16) {
- timecodeSection
- recordingIndicator
- }
- .padding(.bottom, 24)
- // Live Waveform
- waveformSection
- .padding(.horizontal, 24)
- .padding(.bottom, 20)
- // Latest Event Card
- latestEventCard
- .padding(.horizontal, 24)
- .padding(.bottom, 20)
- Spacer()
- // Action Buttons
- actionButtons
- .padding(.horizontal, 48)
- .padding(.bottom, 32)
- // Recording Controls
- recordingControlButtons
- .padding(.bottom, 24)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
- .ignoresSafeArea(.keyboard, edges: .bottom)
- }
- .ignoresSafeArea(.keyboard, edges: .bottom)
- .toolbar(.hidden, for: .tabBar)
- .toolbar(.hidden, for: .navigationBar)
- .navigationBarHidden(true)
- .navigationBarBackButtonHidden(true)
- .sheet(isPresented: $showRecordNameEditor) {
- recordNameEditorSheet
- }
- .sheet(isPresented: $showNoteSheet) {
- noteInputSheet
- }
- .sheet(isPresented: $showPhotoEditor) {
- PhotoRecordEditorSheet(
- timeLabel: formattedNoteTime(pendingPhotoTimeMs)
- ) { drafts, location in
- savePhotosAndAddEvents(drafts, location: location)
- }
- }
- .onAppear {
- UIApplication.shared.isIdleTimerDisabled = true
- recordingVM.startRecording(initialDuration: initialDuration)
- }
- .onChange(of: recordingVM.isRecording) { _, isRecording in
- if isRecording {
- addContinuationMarkerIfNeeded()
- performPendingInitialActionIfReady()
- }
- guard isRecording, !hasStartedLiveActivity else { return }
- hasStartedLiveActivity = true
- RecordingLiveActivityManager.shared.start(
- sessionID: session.id,
- title: session.title,
- sourceName: recordingVM.sourceDisplayName,
- elapsedSeconds: recordingVM.elapsedTime
- )
- }
- .onChange(of: recordingVM.outputFileURL) { _, outputURL in
- guard let outputURL else { return }
- RecordingRecoveryStore.setPendingSegment(outputURL, for: session.id)
- }
- .onChange(of: recordingVM.isPaused) { _, isPaused in
- guard hasStartedLiveActivity else { return }
- RecordingLiveActivityManager.shared.update(
- elapsedSeconds: recordingVM.elapsedTime,
- isPaused: isPaused
- )
- }
- .onOpenURL { url in
- handleRecordingURL(url)
- }
- .onDisappear {
- UIApplication.shared.isIdleTimerDisabled = false
- commitRecordName()
- if recordingVM.isRecording {
- recordingVM.stopRecording()
- }
- if hasStartedLiveActivity {
- RecordingLiveActivityManager.shared.end(elapsedSeconds: recordingVM.elapsedTime)
- hasStartedLiveActivity = false
- }
- }
- .alert("无法结束设备录音", isPresented: Binding(
- get: { stopErrorMessage != nil },
- set: { if !$0 { stopErrorMessage = nil } }
- )) {
- Button("重试") { endRecording() }
- Button("继续录音", role: .cancel) { stopErrorMessage = nil }
- } message: {
- Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。")
- }
- .alert("照片保存失败", isPresented: Binding(
- get: { photoSaveError != nil },
- set: { if !$0 { photoSaveError = nil } }
- )) {
- Button("知道了", role: .cancel) {}
- } message: {
- Text(photoSaveError ?? "请稍后重试。")
- }
- }
- // MARK: - Record Name
- private var recordNameSection: some View {
- Button {
- recordName = session.title
- showRecordNameEditor = true
- } label: {
- VStack(alignment: .leading, spacing: 8) {
- HStack(spacing: 6) {
- Text("记录名称")
- .font(.system(size: 10, weight: .semibold, design: .monospaced))
- .tracking(1.4)
- Spacer()
- Image(systemName: "pencil")
- .font(.system(size: 11, weight: .semibold))
- }
- .foregroundStyle(Color.secondary)
- Text(session.title)
- .font(.system(size: 19, weight: .semibold))
- .foregroundStyle(Color.primary)
- .lineLimit(1)
- .frame(maxWidth: .infinity, alignment: .leading)
- Rectangle()
- .fill(Color.primary.opacity(0.16))
- .frame(height: 1)
- }
- .padding(.horizontal, 14)
- .padding(.vertical, 12)
- .background(Color.cardBackground.opacity(0.45))
- .businessBorder(cornerRadius: 8)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- }
- private var recordNameEditorSheet: some View {
- RecordNameEditorSheet(recordName: $recordName) {
- saveRecordNameAndCloseEditor()
- } onCancel: {
- recordName = session.title
- showRecordNameEditor = false
- }
- }
- // MARK: - Timecode
- private var timecodeSection: some View {
- Text(recordingVM.elapsedTimeFormatted)
- .font(.system(size: 52, weight: .light))
- .monospacedDigit()
- .foregroundStyle(Color.primary)
- .contentTransition(.numericText(countsDown: false))
- .frame(height: 64)
- }
- // MARK: - Recording Indicator
- private var recordingIndicator: some View {
- VStack(spacing: 8) {
- HStack(spacing: 8) {
- TimelineView(.periodic(from: .now, by: 1.0)) { context in
- let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0
- Circle()
- .fill(recordingVM.isRecording && !recordingVM.isPaused ? Color.recordingRed : Color.secondary)
- .frame(width: 8, height: 8)
- .opacity(recordingVM.isRecording && !recordingVM.isPaused ? (isEven ? 1.0 : 0.3) : 0.6)
- .animation(.easeInOut(duration: 0.5), value: isEven)
- }
- .frame(width: 10, height: 10)
- Text(recordingVM.isPaused ? "已暂停" : recordingVM.statusMessage)
- .font(.system(size: 11, weight: .semibold, design: .monospaced))
- .foregroundStyle(recordingVM.isRecording && !recordingVM.isPaused ? Color.recordingRed : Color.secondary)
- .tracking(1.2)
- }
- HStack(spacing: 6) {
- Image(systemName: recordingSource.systemImage)
- .font(.system(size: 11, weight: .medium))
- Text("设备:\(recordingVM.sourceDisplayName)")
- .font(.system(size: 11, weight: .medium))
- }
- .foregroundStyle(Color.primary.opacity(0.85))
- if let error = recordingVM.errorMessage {
- Text(error)
- .font(.system(size: 10))
- .foregroundStyle(Color.recordingRed)
- .multilineTextAlignment(.center)
- .lineLimit(2)
- }
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .businessBorder(cornerRadius: 6)
- }
- // MARK: - Waveform
- private var waveformSection: some View {
- VStack(spacing: 0) {
- LiveWaveformView(samples: recordingVM.waveformSamples)
- .frame(height: 90)
- // Graphical VU Meter + Numeric Display
- HStack(spacing: 10) {
- AmplitudeLevelMeterView(amplitude: recordingVM.currentAmplitude)
- Spacer(minLength: 0)
- Text(String(format: "%02.0f%%", recordingVM.currentAmplitude * 100))
- .font(.system(size: 9, weight: .medium, design: .monospaced))
- .foregroundStyle(recordingVM.currentAmplitude > 0.85 ? Color.recordingRed : Color.secondary)
- }
- .padding(.horizontal, 4)
- .padding(.top, 10)
- }
- .frame(height: 114)
- }
- // MARK: - Latest Event Card
- private var latestEventCard: some View {
- ZStack { // Fix: Use ZStack instead of Group to enforce the fixed frame even when empty
- if let event = latestEvent, latestEventVisible {
- HStack(spacing: 12) {
- Image(systemName: event.eventIcon)
- .font(.system(size: 12))
- .foregroundStyle(Color.primary)
- .frame(width: 28, height: 28)
- .businessBorder(cornerRadius: 14)
- VStack(alignment: .leading, spacing: 2) {
- Text(eventLabel(for: event.eventType))
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(Color.primary)
- if let text = event.textContent {
- Text(text)
- .font(.system(size: 12))
- .foregroundStyle(Color.secondary)
- .lineLimit(1)
- }
- }
- Spacer()
- Text(event.relativeTimeFormatted)
- .font(.system(size: 11, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.secondary)
- }
- .padding(12)
- .background(Color.cardBackground.opacity(0.4))
- .businessBorder(cornerRadius: 8)
- .transition(.asymmetric(
- insertion: .move(edge: .bottom).combined(with: .opacity),
- removal: .opacity
- ))
- }
- }
- .frame(height: 56)
- .animation(.spring(response: 0.35, dampingFraction: 0.8), value: latestEvent?.id)
- }
- // MARK: - Action Buttons
- private var actionButtons: some View {
- HStack(spacing: 40) {
- // Camera button
- actionButton(
- icon: "camera",
- label: "拍照打点"
- ) {
- capturePhoto()
- }
- // Note button
- actionButton(
- icon: "doc.text",
- label: "文字笔记"
- ) {
- presentNoteSheet()
- }
- }
- }
- private func actionButton(
- icon: String,
- label: String,
- action: @escaping () -> Void
- ) -> some View {
- Button(action: action) {
- VStack(spacing: 10) {
- ZStack {
- Circle()
- .fill(Color.primary.opacity(0.02))
- .frame(width: 60, height: 60)
- .businessBorder(cornerRadius: 30)
- Image(systemName: icon)
- .font(.system(size: 20, weight: .light))
- .foregroundStyle(Color.primary)
- }
- Text(label)
- .font(.system(size: 11, weight: .regular))
- .foregroundStyle(Color.secondary)
- }
- }
- .buttonStyle(.plain)
- }
- // MARK: - Recording Controls
- private var recordingControlButtons: some View {
- HStack(spacing: 52) {
- recordingControlButton(
- icon: recordingVM.isPaused ? "play.fill" : "pause.fill",
- label: recordingVM.isPaused ? "继续记录" : "暂停",
- tint: .primary
- ) {
- toggleRecordingPause()
- }
- recordingControlButton(
- icon: "stop.fill",
- label: isEndingRecording ? "正在结束" : "结束记录",
- tint: .recordingRed,
- isProminent: true
- ) {
- endRecording()
- }
- }
- }
- private func recordingControlButton(
- icon: String,
- label: String,
- tint: Color,
- isProminent: Bool = false,
- action: @escaping () -> Void
- ) -> some View {
- Button(action: action) {
- VStack(spacing: 9) {
- ZStack {
- Circle()
- .fill(isProminent ? tint : Color.primary.opacity(0.02))
- .frame(width: 64, height: 64)
- if !isProminent {
- Circle()
- .stroke(Color.primary.opacity(0.18), lineWidth: 1)
- .frame(width: 64, height: 64)
- }
- Image(systemName: icon)
- .font(.system(size: 21, weight: .semibold))
- .foregroundStyle(isProminent ? Color.white : tint)
- }
- Text(label)
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(tint)
- }
- .frame(width: 88)
- }
- .buttonStyle(.plain)
- .disabled(isEndingRecording)
- .opacity(isEndingRecording && !isProminent ? 0.4 : 1)
- .accessibilityLabel(label)
- }
- // MARK: - Note Input Sheet
- private var noteInputSheet: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- VStack(alignment: .leading, spacing: 16) {
- Label(
- "添加到 \(formattedNoteTime(pendingNoteTimeMs))",
- systemImage: "clock"
- )
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(Color.secondary)
- ZStack(alignment: .topLeading) {
- if noteText.isEmpty {
- Text("这一刻的想法")
- .font(.system(size: 15))
- .foregroundStyle(Color.secondary.opacity(0.6))
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .allowsHitTesting(false)
- }
- TextEditor(text: $noteText)
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .scrollContentBackground(.hidden)
- .padding(10)
- .frame(minHeight: 160)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- }
- TimelineLocationButton(location: $noteLocation)
- Spacer()
- }
- .padding(20)
- }
- .navigationTitle("添加笔记")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("取消") {
- showNoteSheet = false
- noteText = ""
- noteLocation = nil
- }
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("保存") {
- addNote()
- }
- .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- }
- }
- .presentationDetents([.medium])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- // MARK: - Actions
- private func capturePhoto() {
- pendingPhotoTimeMs = max(0, recordingVM.elapsedTime * 1_000)
- showPhotoEditor = true
- }
- private func savePhotosAndAddEvents(
- _ drafts: [PhotoRecordDraft],
- location: TimelineLocation?
- ) -> Bool {
- guard !drafts.isEmpty,
- let documentsURL = FileManager.default.urls(
- for: .documentDirectory,
- in: .userDomainMask
- ).first else {
- photoSaveError = "无法访问本地照片目录。"
- return false
- }
- var storedPhotos: [(filename: String, fileURL: URL, note: String)] = []
- do {
- for draft in drafts {
- guard let data = draft.image.jpegData(compressionQuality: 0.82) else {
- throw CocoaError(.fileWriteUnknown)
- }
- let filename = "photo_\(UUID().uuidString).jpg"
- let fileURL = documentsURL.appendingPathComponent(filename)
- try data.write(to: fileURL, options: .atomic)
- storedPhotos.append((filename, fileURL, draft.note))
- }
- } catch {
- for storedPhoto in storedPhotos {
- try? FileManager.default.removeItem(at: storedPhoto.fileURL)
- }
- photoSaveError = "照片文件写入失败:\(error.localizedDescription)"
- return false
- }
- let relativeTimeMs = Int64(pendingPhotoTimeMs.rounded())
- let events = storedPhotos.map { storedPhoto in
- let event = recordingVM.addPhotoEvent(
- to: session,
- relativeTimeMs: relativeTimeMs,
- localFilePath: storedPhoto.filename,
- note: storedPhoto.note
- )
- event.location = location
- return event
- }
- HapticManager.trigger(.photoCapture)
- if let latestPhotoEvent = events.last {
- showLatestEvent(latestPhotoEvent)
- }
- return true
- }
- private func addNote() {
- let text = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !text.isEmpty else { return }
- let event = recordingVM.addNoteEvent(to: session, text: text)
- event.relativeTimeMs = Int64(pendingNoteTimeMs.rounded())
- event.location = noteLocation
- HapticManager.trigger(.noteAdded)
- showLatestEvent(event)
- noteText = ""
- noteLocation = nil
- showNoteSheet = false
- }
- private func addContinuationMarkerIfNeeded() {
- guard initialDuration > 0, !hasAddedContinuationMarker else { return }
- hasAddedContinuationMarker = true
- let event = CelestiaTimelineEvent(
- relativeTimeMs: Int64((initialDuration * 1_000).rounded()),
- eventType: "MARKER"
- )
- event.textContent = "续录时间:\(Self.continuationDateFormatter.string(from: Date()))"
- session.events.append(event)
- session.isSynced = false
- session.syncState = .pending
- try? modelContext.save()
- }
- private func presentNoteSheet() {
- pendingNoteTimeMs = max(0, recordingVM.elapsedTime * 1_000)
- noteText = ""
- noteLocation = nil
- showNoteSheet = true
- }
- private func formattedNoteTime(_ timeMs: Double) -> String {
- let totalSeconds = max(0, Int(timeMs / 1_000))
- let hours = totalSeconds / 3_600
- let minutes = (totalSeconds % 3_600) / 60
- let seconds = totalSeconds % 60
- if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, seconds)
- }
- return String(format: "%02d:%02d", minutes, seconds)
- }
- private static let continuationDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
- return formatter
- }()
- private func toggleRecordingPause() {
- guard !isEndingRecording else { return }
- HapticManager.trigger(.tapFeedback)
- if recordingVM.isPaused {
- recordingVM.resumeRecording()
- } else {
- recordingVM.pauseRecording()
- }
- }
- private func endRecording() {
- guard !isEndingRecording else { return }
- commitRecordName()
- isEndingRecording = true
- recordingVM.stopRecording { result in
- switch result {
- case .success(let confirmedURL):
- finalizeRecording(newRecordedURL: confirmedURL ?? recordingVM.outputFileURL)
- case .failure(let error):
- isEndingRecording = false
- stopErrorMessage = error.localizedDescription
- }
- }
- }
- private func finalizeRecording(newRecordedURL: URL?) {
- let existingPath = session.localAudioPath
- let existingURL = AudioPathHelper.resolveURL(for: existingPath)
- let totalRecordedSeconds = recordingVM.elapsedTime
- RecordingLiveActivityManager.shared.end(elapsedSeconds: totalRecordedSeconds)
- hasStartedLiveActivity = false
-
- if let newRecordedURL = newRecordedURL {
- Task { @MainActor in
- let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL)
- session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path)
- session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
- session.markContentModified()
- try? modelContext.save()
- scheduleAutomaticSync()
- RecordingRecoveryStore.clear(sessionID: session.id)
-
- HapticManager.trigger(.recordStop)
- isEndingRecording = false
- dismiss()
- onFinishRecording?()
- }
- } else {
- if existingURL != nil {
- session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
- session.markContentModified()
- try? modelContext.save()
- } else {
- session.endTime = Date()
- session.markContentModified()
- try? modelContext.save()
- }
- scheduleAutomaticSync()
- RecordingRecoveryStore.clear(sessionID: session.id)
- HapticManager.trigger(.recordStop)
- isEndingRecording = false
- dismiss()
- onFinishRecording?()
- }
- }
- private func showLatestEvent(_ event: CelestiaTimelineEvent) {
- withAnimation {
- latestEvent = event
- latestEventVisible = true
- }
- // Auto-dismiss after a few seconds
- DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
- withAnimation {
- latestEventVisible = false
- }
- }
- }
- // MARK: - Helpers
- private func eventLabel(for type: String) -> String {
- switch type {
- case "PHOTO": return "已捕获照片"
- case "NOTE": return "已保存笔记"
- case "MARKER": return "标记点"
- case "VOICE": return "音轨事件"
- default: return "事件"
- }
- }
- private func commitRecordName() {
- let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmedName.isEmpty {
- recordName = session.title
- return
- }
- guard session.title != trimmedName else {
- if recordName != trimmedName {
- recordName = trimmedName
- }
- return
- }
- recordName = trimmedName
- session.title = trimmedName
- session.markContentModified()
- try? modelContext.save()
- }
- private func scheduleAutomaticSync() {
- syncManager.scheduleAutomaticSync(
- for: session,
- modelContext: modelContext
- )
- }
- private func saveRecordNameAndCloseEditor() {
- commitRecordName()
- guard !recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
- showRecordNameEditor = false
- }
- private func handleRecordingURL(_ url: URL) {
- guard let route = RecordingLiveActivityRoute(url: url),
- route.sessionID == session.id else { return }
- perform(route.action)
- }
- private func performPendingInitialActionIfReady() {
- guard recordingVM.isRecording, let action = pendingInitialAction else { return }
- pendingInitialAction = nil
- perform(action)
- }
- private func perform(_ action: RecordingLiveActivityAction?) {
- switch action {
- case .photo:
- capturePhoto()
- case .note:
- presentNoteSheet()
- case .togglePause:
- toggleRecordingPause()
- case .stop:
- endRecording()
- case nil:
- return
- }
- }
- }
- struct RecordNameEditorSheet: View {
- @Binding var recordName: String
- let onSave: () -> Void
- let onCancel: () -> Void
- var body: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- VStack(alignment: .leading, spacing: 10) {
- Text("记录名称")
- .font(.system(size: 10, weight: .semibold, design: .monospaced))
- .foregroundStyle(Color.secondary)
- .tracking(1.4)
- SuffixSelectingTextField(
- placeholder: "输入记录名称",
- text: $recordName,
- selectedSuffix: "现场记录",
- onSubmit: onSave
- )
- .padding(.horizontal, 14)
- .padding(.vertical, 12)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- Spacer()
- }
- .padding(20)
- }
- .navigationTitle("修改记录名称")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("取消", action: onCancel)
- .foregroundStyle(Color.secondary)
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("保存", action: onSave)
- .disabled(recordName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- }
- }
- .presentationDetents([.height(190)])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- }
- private struct SuffixSelectingTextField: UIViewRepresentable {
- let placeholder: String
- @Binding var text: String
- let selectedSuffix: String
- let onSubmit: () -> Void
- func makeCoordinator() -> Coordinator {
- Coordinator(text: $text, selectedSuffix: selectedSuffix, onSubmit: onSubmit)
- }
- func makeUIView(context: Context) -> UITextField {
- let textField = UITextField()
- textField.placeholder = placeholder
- textField.text = text
- textField.font = .systemFont(ofSize: 18, weight: .semibold)
- textField.textColor = .label
- textField.tintColor = UIColor(Color.accentColor)
- textField.autocapitalizationType = .none
- textField.autocorrectionType = .no
- textField.returnKeyType = .done
- textField.delegate = context.coordinator
- textField.addTarget(
- context.coordinator,
- action: #selector(Coordinator.textDidChange(_:)),
- for: .editingChanged
- )
- DispatchQueue.main.async {
- textField.becomeFirstResponder()
- context.coordinator.selectSuffixIfNeeded(in: textField)
- }
- return textField
- }
- func updateUIView(_ textField: UITextField, context: Context) {
- if textField.text != text {
- textField.text = text
- }
- }
- final class Coordinator: NSObject, UITextFieldDelegate {
- @Binding private var text: String
- private let selectedSuffix: String
- private let onSubmit: () -> Void
- private var hasAppliedInitialSelection = false
- init(text: Binding<String>, selectedSuffix: String, onSubmit: @escaping () -> Void) {
- _text = text
- self.selectedSuffix = selectedSuffix
- self.onSubmit = onSubmit
- }
- @objc func textDidChange(_ textField: UITextField) {
- text = textField.text ?? ""
- }
- func textFieldShouldReturn(_ textField: UITextField) -> Bool {
- onSubmit()
- return true
- }
- func selectSuffixIfNeeded(in textField: UITextField) {
- guard !hasAppliedInitialSelection else { return }
- hasAppliedInitialSelection = true
- let fullText = textField.text ?? ""
- guard fullText.hasSuffix(selectedSuffix),
- let start = textField.position(
- from: textField.endOfDocument,
- offset: -selectedSuffix.utf16.count
- ),
- let range = textField.textRange(from: start, to: textField.endOfDocument) else {
- return
- }
- textField.selectedTextRange = range
- }
- }
- }
- // MARK: - Amplitude Level Meter View
- /// A minimalist graphical VU meter bar visualizing real-time audio amplitude.
- private struct AmplitudeLevelMeterView: View {
- let amplitude: Float // 0.0 to 1.0
- private let totalSegments: Int = 16
- var body: some View {
- HStack(spacing: 3) {
- ForEach(0..<totalSegments, id: \.self) { index in
- let threshold = Float(index) / Float(totalSegments)
- let isFilled = amplitude > threshold
- let isPeak = index >= totalSegments - 2
- RoundedRectangle(cornerRadius: 1)
- .fill(
- isFilled
- ? (isPeak ? Color.recordingRed : Color.primary.opacity(0.85))
- : Color.primary.opacity(0.12)
- )
- .frame(height: isFilled ? (isPeak ? 7 : 5) : 3)
- .animation(.spring(response: 0.15, dampingFraction: 0.75), value: amplitude)
- }
- }
- }
- }
- // MARK: - Preview
- #Preview {
- let session = CelestiaSession(title: "Preview Session")
- ActiveRecordingView(session: session)
- .modelContainer(for: CelestiaSession.self, inMemory: true)
- }
|