Преглед на файлове

feat(continuation): 增加续录时间节点自动落盘标记与多轨时间轴可视化

bob.yuxinyang преди 1 месец
родител
ревизия
cdb82d1ead

+ 8 - 0
CelestiaTrace/Models/CelestiaTimelineEvent.swift

@@ -24,8 +24,16 @@ final class CelestiaTimelineEvent {
         }
         return String(format: "%02d:%02d", minutes, seconds)
     }
+
+    var isContinuationMarker: Bool {
+        eventType == "MARKER" && textContent?.hasPrefix("续录时间:") == true
+    }
     
     var eventIcon: String {
+        if isContinuationMarker {
+            return "record.circle"
+        }
+
         switch eventType {
         case "PHOTO": return "camera.fill"
         case "NOTE": return "note.text"

+ 8 - 0
CelestiaTrace/Views/Detail/ChronoFeedRow.swift

@@ -126,6 +126,10 @@ struct ChronoFeedRow: View {
     // MARK: - Computed Properties
 
     private var eventIconName: String {
+        if event.isContinuationMarker {
+            return "record.circle"
+        }
+
         switch event.eventType {
         case "PHOTO": return "camera"
         case "NOTE": return "doc.text"
@@ -136,6 +140,10 @@ struct ChronoFeedRow: View {
     }
 
     private var typeLabel: String {
+        if event.isContinuationMarker {
+            return "续录"
+        }
+
         switch event.eventType {
         case "PHOTO": return "照片"
         case "NOTE": return "笔记"

+ 41 - 5
CelestiaTrace/Views/Detail/MultiTrackTimeline.swift

@@ -27,6 +27,9 @@ struct MultiTrackTimeline: View {
     private let pointsPerSecond: CGFloat = 2.5
     /// Minimum timeline width
     private let minimumWidth: CGFloat = 400
+    /// Matches the live-recording waveform height.
+    private let audioTrackHeight: CGFloat = 90
+    private let eventTrackHeight: CGFloat = 34
 
     private var timelineWidth: CGFloat {
         max(minimumWidth, CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60)
@@ -44,23 +47,23 @@ struct MultiTrackTimeline: View {
                         VStack(spacing: 0) {
                             // Time Ruler
                             timeRuler
-                                .frame(height: 26)
+                                .frame(height: 28)
 
                             // Track 1: Audio Waveform
                             audioTrack
-                                .frame(height: 38)
+                                .frame(height: audioTrackHeight)
 
                             trackDivider
 
                             // Track 2: Photo Markers
                             photoTrack
-                                .frame(height: 28)
+                                .frame(height: eventTrackHeight)
 
                             trackDivider
 
                             // Track 3: Note Markers
                             noteTrack
-                                .frame(height: 28)
+                                .frame(height: eventTrackHeight)
                         }
 
                         // Playhead Cursor
@@ -189,6 +192,29 @@ struct MultiTrackTimeline: View {
             }
 
             trackLeadingIcon(systemName: "waveform", accessibilityLabel: "音频轨道")
+
+            ForEach(continuationEvents) { event in
+                Button {
+                    onEventTap?(event)
+                } label: {
+                    ZStack {
+                        Rectangle()
+                            .fill(Color.primary.opacity(0.35))
+                            .frame(width: 1, height: audioTrackHeight)
+
+                        Image(systemName: "record.circle")
+                            .font(.system(size: 11, weight: .medium))
+                            .foregroundStyle(Color.primary)
+                            .frame(width: 20, height: 20)
+                            .background(Color.cardBackground.opacity(0.9))
+                            .businessBorder(cornerRadius: 10)
+                    }
+                    .frame(width: 20, height: audioTrackHeight)
+                }
+                .buttonStyle(.plain)
+                .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
+                .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的续录信息")
+            }
         }
     }
 
@@ -310,6 +336,9 @@ struct MultiTrackTimeline: View {
             trackLabel(icon: "waveform", text: "音频")
             trackLabel(icon: "camera", text: "图片")
             trackLabel(icon: "doc.text", text: "笔记")
+            if !continuationEvents.isEmpty {
+                trackLabel(icon: "record.circle", text: "续录")
+            }
         }
     }
 
@@ -375,7 +404,14 @@ struct MultiTrackTimeline: View {
     }
 
     private var noteEvents: [CelestiaTimelineEvent] {
-        events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }
+        events.filter {
+            $0.eventType == "NOTE"
+                || ($0.eventType == "MARKER" && !$0.isContinuationMarker)
+        }
+    }
+
+    private var continuationEvents: [CelestiaTimelineEvent] {
+        events.filter(\.isContinuationMarker)
     }
 }
 

+ 35 - 2
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -196,6 +196,7 @@ struct SessionDetailView: View {
     }
 
     private func prepareContinuation() {
+        playbackVM.pause()
         detector.checkEnvironment()
         if let firstWarning = detector.activeWarnings.first {
             activeWarningMsg = firstWarning
@@ -359,6 +360,31 @@ struct SessionDetailView: View {
                 }
                 .frame(maxWidth: .infinity)
             }
+
+            Divider()
+                .background(Color.lineBorder)
+
+            Button {
+                showSessionInfo = false
+                DispatchQueue.main.async {
+                    prepareContinuation()
+                }
+            } label: {
+                HStack(spacing: 8) {
+                    Image(systemName: "record.circle")
+                        .font(.system(size: 14, weight: .medium))
+
+                    Text("续录")
+                        .font(.system(size: 13, weight: .semibold))
+                }
+                .foregroundStyle(Color.primary)
+                .frame(maxWidth: .infinity)
+                .padding(.vertical, 10)
+                .background(Color.cardBackground.opacity(0.2))
+                .businessBorder(cornerRadius: 8)
+            }
+            .buttonStyle(.plain)
+            .accessibilityLabel("继续现场记录")
         }
         .padding(14)
         .background(Color.cardBackground.opacity(0.15))
@@ -411,7 +437,7 @@ struct SessionDetailView: View {
                     HapticManager.trigger(.tapFeedback)
                 }
             )
-            .frame(height: 140)
+            .frame(height: 220)
         }
     }
 
@@ -797,7 +823,7 @@ private struct TimelineEventDetailSheet: View {
                     .padding(20)
                 }
             }
-            .navigationTitle(event.eventType == "PHOTO" ? "查看图片" : "查看笔记")
+            .navigationTitle(detailTitle)
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
                 ToolbarItem(placement: .confirmationAction) {
@@ -857,6 +883,13 @@ private struct TimelineEventDetailSheet: View {
                 .businessBorder(cornerRadius: 8)
         }
     }
+
+    private var detailTitle: String {
+        if event.isContinuationMarker {
+            return "续录详情"
+        }
+        return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
+    }
 }
 
 private struct AudioShareItem: Identifiable {

+ 27 - 0
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -26,6 +26,7 @@ struct ActiveRecordingView: View {
     @State private var recordName: String
     @State private var showRecordNameEditor = false
     @State private var hasStartedLiveActivity = false
+    @State private var hasAddedContinuationMarker = false
 
     init(
         session: CelestiaSession,
@@ -109,6 +110,9 @@ struct ActiveRecordingView: View {
             recordingVM.startRecording(initialDuration: initialDuration)
         }
         .onChange(of: recordingVM.isRecording) { _, isRecording in
+            if isRecording {
+                addContinuationMarkerIfNeeded()
+            }
             guard isRecording, !hasStartedLiveActivity else { return }
             hasStartedLiveActivity = true
             RecordingLiveActivityManager.shared.start(
@@ -523,6 +527,21 @@ struct ActiveRecordingView: View {
         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 = ""
@@ -541,6 +560,14 @@ struct ActiveRecordingView: View {
         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)