import SwiftUI // MARK: - MultiTrackTimeline /// A three-track horizontal timeline visualization showing: /// - Track 1: Audio availability and silence markers /// - Track 2: Photo markers (camera outlines at event positions) /// - Track 3: Note/marker outline icons at event positions /// Includes a time ruler and a central playhead cursor. /// Fully redesigned with a minimalist business line-drawn style. struct MultiTrackTimeline: View { enum EditableTrack { case photo case note } private struct PhotoEventGroup: Identifiable { let relativeTimeMs: Int64 let events: [CelestiaTimelineEvent] var id: UUID { events[0].id } } let events: [CelestiaTimelineEvent] @Binding var currentTimeMs: Double let totalDurationMs: Double var waveformSamples: [Float] = [] var waveformRevision: Int = 0 var silentRanges: [SilenceRange] = [] var onEmptyTrackTap: ((EditableTrack, Double) -> Void)? var onEventTap: ((CelestiaTimelineEvent) -> Void)? var onScrubBegan: (() -> Void)? var onScrubEnded: ((Double) -> Void)? @State private var playheadDragStartTimeMs: Double? @State private var lastAutoScrollTimeMs = -Double.greatestFiniteMagnitude @State private var viewportWidth: CGFloat = 0 @State private var zoomScale: CGFloat = 1 @GestureState private var gestureMagnification: CGFloat = 1 /// Scale: points per second private let pointsPerSecond: CGFloat = 2.5 private let maximumZoomScale: CGFloat = 8 /// Matches the live-recording waveform height. private let audioTrackHeight: CGFloat = 90 private let eventTrackHeight: CGFloat = 34 private var timelineWidth: CGFloat { let durationWidth = CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60 return max(viewportWidth, durationWidth) * effectiveZoomScale } private var effectiveZoomScale: CGFloat { min(max(zoomScale * gestureMagnification, 1), maximumZoomScale) } var body: some View { VStack(spacing: 0) { ScrollViewReader { scrollProxy in ScrollView(.horizontal, showsIndicators: false) { ZStack(alignment: .topLeading) { // Background RoundedRectangle(cornerRadius: 10) .fill(Color.cardBackground.opacity(0.15)) VStack(spacing: 0) { // Time Ruler timeRuler .frame(height: 28) // Track 1: Audio Waveform audioTrack .frame(height: audioTrackHeight) trackDivider // Track 2: Photo Markers photoTrack .frame(height: eventTrackHeight) trackDivider // Track 3: Note Markers noteTrack .frame(height: eventTrackHeight) } // Playhead Cursor playhead .id("playhead") // Event hit targets stay above the draggable playhead so // overlapping photo and note markers remain tappable. eventHitTargets } .frame(width: timelineWidth) } .clipShape(RoundedRectangle(cornerRadius: 10)) .businessBorder(cornerRadius: 10) .background { GeometryReader { geometry in Color.clear .onAppear { viewportWidth = geometry.size.width } .onChange(of: geometry.size.width) { _, newWidth in viewportWidth = newWidth } } } .simultaneousGesture(timelineMagnificationGesture) .onChange(of: currentTimeMs) { _, newValue in guard playheadDragStartTimeMs == nil else { return } guard abs(newValue - lastAutoScrollTimeMs) >= 750 || newValue <= 0 || newValue >= totalDurationMs else { return } lastAutoScrollTimeMs = newValue withAnimation(.easeOut(duration: 0.2)) { scrollProxy.scrollTo("playhead", anchor: .center) } } } // Track labels trackLabels .padding(.top, 8) } } private var timelineMagnificationGesture: some Gesture { MagnifyGesture() .updating($gestureMagnification) { value, state, _ in state = value.magnification } .onEnded { value in zoomScale = min( max(zoomScale * value.magnification, 1), maximumZoomScale ) } } // MARK: - Time Ruler private var timeRuler: some View { Canvas { context, size in let totalSeconds = totalDurationMs / 1000.0 let markerInterval: Double = totalSeconds > 300 ? 60 : (totalSeconds > 60 ? 10 : 5) var t: Double = 0 while t <= totalSeconds { let x = xPosition(for: t * 1000) // Tick mark let tickPath = Path { path in path.move(to: CGPoint(x: x, y: size.height - 6)) path.addLine(to: CGPoint(x: x, y: size.height)) } context.stroke(tickPath, with: .color(Color.primary.opacity(0.2)), lineWidth: 1) // Time label let minutes = Int(t) / 60 let seconds = Int(t) % 60 let label = String(format: "%d:%02d", minutes, seconds) let text = Text(label) .font(.system(size: 8, weight: .regular, design: .monospaced)) .foregroundStyle(Color.secondary.opacity(0.8)) context.draw( context.resolve(text), at: CGPoint(x: x, y: size.height - 14), anchor: .center ) t += markerInterval } } } // MARK: - Audio Track private var audioTrack: some View { ZStack(alignment: .leading) { PlaybackWaveformCanvas( totalDurationMs: totalDurationMs, waveformSamples: waveformSamples, silentRanges: silentRanges, revision: waveformRevision ) .equatable() 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) 的续录信息") } } } // MARK: - Photo Track private var photoTrack: some View { ZStack(alignment: .leading) { emptyTrackTapTarget(for: .photo) trackLeadingIcon(systemName: "camera", accessibilityLabel: "图片轨道") ForEach(photoEventGroups) { group in Button { onEventTap?(group.events[0]) } label: { ZStack(alignment: .topTrailing) { Image(systemName: "camera") .font(.system(size: 9)) .foregroundStyle(Color.primary.opacity(0.8)) .frame(width: 20, height: 20) .background(Color.cardBackground.opacity(0.6)) .businessBorder(cornerRadius: 10) if group.events.count > 1 { Text("\(group.events.count)") .font(.system(size: 7, weight: .bold, design: .rounded)) .foregroundStyle(Color.spaceBlack) .frame(minWidth: 13, minHeight: 13) .background(Color.primary, in: Circle()) .offset(x: 5, y: -5) } } } .buttonStyle(.plain) .offset(x: xPosition(for: Double(group.relativeTimeMs)) - 10) .accessibilityLabel( "查看 \(group.events[0].relativeTimeFormatted) 的 \(group.events.count) 张图片" ) } } } // MARK: - Note Track private var noteTrack: some View { ZStack(alignment: .leading) { emptyTrackTapTarget(for: .note) trackLeadingIcon(systemName: "doc.text", accessibilityLabel: "笔记轨道") ForEach(noteEvents) { event in let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text" Button { onEventTap?(event) } label: { Image(systemName: iconName) .font(.system(size: 9)) .foregroundStyle(Color.primary.opacity(0.8)) .frame(width: 20, height: 20) .background(Color.cardBackground.opacity(0.6)) .businessBorder(cornerRadius: 10) } .buttonStyle(.plain) .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10) .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的笔记") } } } // MARK: - Playhead private var eventHitTargets: some View { ZStack(alignment: .topLeading) { ForEach(photoEventGroups) { group in Button { onEventTap?(group.events[0]) } label: { Color.clear .contentShape(Rectangle()) .frame(width: 28, height: eventTrackHeight) } .buttonStyle(.plain) .offset( x: xPosition(for: Double(group.relativeTimeMs)) - 14, y: 28 + audioTrackHeight + 1 ) .accessibilityHidden(true) } ForEach(noteEvents) { event in Button { onEventTap?(event) } label: { Color.clear .contentShape(Rectangle()) .frame(width: 28, height: eventTrackHeight) } .buttonStyle(.plain) .offset( x: xPosition(for: Double(event.relativeTimeMs)) - 14, y: 28 + audioTrackHeight + 1 + eventTrackHeight + 1 ) .accessibilityHidden(true) } } .frame( width: timelineWidth, height: 28 + audioTrackHeight + 2 + eventTrackHeight * 2, alignment: .topLeading ) } private var playhead: some View { let x = xPosition(for: currentTimeMs) return ZStack { Color.clear .contentShape(Rectangle()) Rectangle() .fill(Color.primary) .frame(width: 1) } .frame(width: 28) .offset(x: x - 14) .animation( playheadDragStartTimeMs == nil ? .easeOut(duration: 0.15) : nil, value: currentTimeMs ) .highPriorityGesture(playheadDragGesture) .accessibilityLabel("当前播放位置") .accessibilityValue(formattedTime(currentTimeMs)) .accessibilityAdjustableAction { direction in let stepMs = 1_000.0 onScrubBegan?() switch direction { case .increment: currentTimeMs = min(totalDurationMs, currentTimeMs + stepMs) case .decrement: currentTimeMs = max(0, currentTimeMs - stepMs) @unknown default: break } onScrubEnded?(currentTimeMs) } } private var playheadDragGesture: some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { value in let startTime = playheadDragStartTimeMs ?? currentTimeMs if playheadDragStartTimeMs == nil { playheadDragStartTimeMs = startTime onScrubBegan?() } let startX = xPosition(for: startTime) currentTimeMs = timeMs(forXPosition: startX + value.translation.width) } .onEnded { _ in playheadDragStartTimeMs = nil onScrubEnded?(currentTimeMs) } } // MARK: - Track Divider private var trackDivider: some View { Rectangle() .fill(Color.lineBorder) .frame(height: 1) } // MARK: - Track Labels private var trackLabels: some View { HStack(spacing: 24) { trackLabel(icon: "waveform", text: "音频") trackLabel(icon: "camera", text: "图片") trackLabel(icon: "doc.text", text: "笔记") if !continuationEvents.isEmpty { trackLabel(icon: "record.circle", text: "续录") } } } private func trackLabel(icon: String, text: String) -> some View { HStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 9, weight: .light)) Text(text) .font(.system(size: 9, weight: .regular)) } .foregroundStyle(Color.secondary) } private func trackLeadingIcon( systemName: String, accessibilityLabel: String ) -> some View { Image(systemName: systemName) .font(.system(size: 10, weight: .regular)) .foregroundStyle(Color.secondary.opacity(0.85)) .frame(width: 24) .accessibilityLabel(accessibilityLabel) } private func emptyTrackTapTarget(for track: EditableTrack) -> some View { Color.clear .contentShape(Rectangle()) .gesture( SpatialTapGesture() .onEnded { value in let timeMs = timeMs(forXPosition: value.location.x) onEmptyTrackTap?(track, timeMs) } ) } // MARK: - Helpers private func xPosition(for timeMs: Double) -> CGFloat { guard totalDurationMs > 0 else { return 30 } let padding: CGFloat = 30 let usableWidth = timelineWidth - padding * 2 let ratio = min(max(timeMs / totalDurationMs, 0), 1) return padding + usableWidth * CGFloat(ratio) } private func timeMs(forXPosition x: CGFloat) -> Double { guard totalDurationMs > 0 else { return 0 } let padding: CGFloat = 30 let usableWidth = timelineWidth - padding * 2 guard usableWidth > 0 else { return 0 } let ratio = min(max((x - padding) / usableWidth, 0), 1) return Double(ratio) * totalDurationMs } private func formattedTime(_ timeMs: Double) -> String { let totalSeconds = max(0, Int(timeMs / 1_000)) return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60) } private var photoEvents: [CelestiaTimelineEvent] { events.filter { $0.eventType == "PHOTO" } } private var photoEventGroups: [PhotoEventGroup] { Dictionary(grouping: photoEvents, by: \.relativeTimeMs) .map { relativeTimeMs, events in PhotoEventGroup( relativeTimeMs: relativeTimeMs, events: events.sorted { if $0.createdAt == $1.createdAt { return $0.id.uuidString < $1.id.uuidString } return $0.createdAt < $1.createdAt } ) } .sorted { $0.relativeTimeMs < $1.relativeTimeMs } } private var noteEvents: [CelestiaTimelineEvent] { events.filter { $0.eventType == "NOTE" || ($0.eventType == "MARKER" && !$0.isContinuationMarker) } } private var continuationEvents: [CelestiaTimelineEvent] { events.filter(\.isContinuationMarker) } } /// The expensive waveform aggregation is isolated from the frequently moving /// playhead. Its equality check uses the view-model revision instead of /// comparing every sample on each 100ms playback update. private struct PlaybackWaveformCanvas: View, Equatable { let totalDurationMs: Double let waveformSamples: [Float] let silentRanges: [SilenceRange] let revision: Int static func == (lhs: Self, rhs: Self) -> Bool { lhs.revision == rhs.revision && lhs.totalDurationMs == rhs.totalDurationMs } var body: some View { Canvas { context, size in let centerY = size.height / 2 let barWidth: CGFloat = 1 let barSpacing: CGFloat = 2 let barSlot = barWidth + barSpacing let startX: CGFloat = 30 let endX = max(startX, size.width - 30) let waveformWidth = max(0, endX - startX) let barCount = max(0, Int(waveformWidth / barSlot)) for range in silentRanges { guard totalDurationMs > 0 else { continue } let rangeStart = startX + waveformWidth * CGFloat(max(0, range.start * 1_000) / totalDurationMs) let rangeEnd = startX + waveformWidth * CGFloat(min(totalDurationMs, range.end * 1_000) / totalDurationMs) guard rangeEnd > rangeStart else { continue } context.fill( Path(CGRect( x: rangeStart, y: 0, width: rangeEnd - rangeStart, height: size.height )), with: .color(Color.secondary.opacity(0.10)) ) } let centerLine = Path { path in path.move(to: CGPoint(x: startX, y: centerY)) path.addLine(to: CGPoint(x: endX, y: centerY)) } context.stroke( centerLine, with: .color(Color.primary.opacity(0.1)), lineWidth: 1 ) guard barCount > 0, !waveformSamples.isEmpty else { return } let envelopes = WaveformMinMaxDownsampler.envelopes( waveformSamples, targetBucketCount: barCount ) guard !envelopes.isEmpty else { return } let bucketWidth = waveformWidth / CGFloat(envelopes.count) for (index, envelope) in envelopes.enumerated() { let maximumHeight = max( CGFloat(max(envelope.maximum, 0)) * size.height * 0.75, 1 ) let x = startX + CGFloat(index) * bucketWidth context.fill( Path(CGRect( x: x, y: centerY - maximumHeight / 2, width: min(barWidth, bucketWidth), height: maximumHeight )), with: .color(Color.primary.opacity(0.58)) ) let minimumHeight = CGFloat(max(envelope.minimum, 0)) * size.height * 0.75 if minimumHeight >= 1 { context.fill( Path(CGRect( x: x, y: centerY - minimumHeight / 2, width: min(barWidth, bucketWidth), height: minimumHeight )), with: .color(Color.primary.opacity(0.9)) ) } } } } } private struct WaveformEnvelope { let minimum: Float let maximum: Float } /// Reduces an arbitrarily long recording to at most one min/max envelope per /// drawable screen bucket. Peaks survive zooming and scrolling without /// creating one SwiftUI element (or one Canvas draw) per source sample. private enum WaveformMinMaxDownsampler { static func envelopes( _ samples: [Float], targetBucketCount: Int ) -> [WaveformEnvelope] { guard !samples.isEmpty, targetBucketCount > 0 else { return [] } let bucketCount = min(samples.count, targetBucketCount) var result: [WaveformEnvelope] = [] result.reserveCapacity(bucketCount) for bucket in 0..