import SwiftUI // MARK: - MultiTrackTimeline /// A three-track horizontal timeline visualization showing: /// - Track 1: Audio waveform (mock sine wave outline) /// - 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 { let events: [CelestiaTimelineEvent] @Binding var currentTimeMs: Double let totalDurationMs: Double var silentRanges: [SilenceRange] = [] /// Scale: points per second private let pointsPerSecond: CGFloat = 2.5 /// Minimum timeline width private let minimumWidth: CGFloat = 400 private var timelineWidth: CGFloat { max(minimumWidth, CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60) } 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: 26) // Track 1: Audio Waveform audioTrack .frame(height: 38) trackDivider // Track 2: Photo Markers photoTrack .frame(height: 28) trackDivider // Track 3: Note Markers noteTrack .frame(height: 28) } // Playhead Cursor playhead .id("playhead") } .frame(width: timelineWidth) } .clipShape(RoundedRectangle(cornerRadius: 10)) .businessBorder(cornerRadius: 10) .onChange(of: currentTimeMs) { _, _ in withAnimation(.easeOut(duration: 0.2)) { scrollProxy.scrollTo("playhead", anchor: .center) } } } // Track labels trackLabels .padding(.top, 8) } } // 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 { Canvas { context, size in let sampleCount = Int(size.width) guard sampleCount > 0 else { return } let waveformPath = Path { path in for i in 0..= $0.start && time < $0.end }) let amplitude: Double if isInSilence { amplitude = 0.02 } else { amplitude = abs( sin(time * 3.0) * 0.45 + sin(time * 7.3) * 0.2 + sin(time * 13.7) * 0.1 + sin(time * 0.8) * 0.15 ) } let height = amplitude * Double(size.height * 0.7) let centerY = size.height / 2 if i == 0 { path.move(to: CGPoint(x: x, y: centerY - height / 2)) } else { path.addLine(to: CGPoint(x: x, y: centerY - height / 2)) } } // Mirror back for i in stride(from: sampleCount - 1, through: 0, by: -1) { let x = CGFloat(i) let progress = Double(i) / Double(sampleCount) let time = progress * (totalDurationMs / 1000.0) let isInSilence = silentRanges.contains(where: { time >= $0.start && time < $0.end }) let amplitude: Double if isInSilence { amplitude = 0.02 } else { amplitude = abs( sin(time * 3.0) * 0.45 + sin(time * 7.3) * 0.2 + sin(time * 13.7) * 0.1 + sin(time * 0.8) * 0.15 ) } let height = amplitude * Double(size.height * 0.7) let centerY = size.height / 2 path.addLine(to: CGPoint(x: x, y: centerY + height / 2)) } path.closeSubpath() } // Fill with a very subtle neutral monochrome shade context.fill(waveformPath, with: .color(Color.primary.opacity(0.08))) // Draw clean outline context.stroke(waveformPath, with: .color(Color.primary.opacity(0.35)), lineWidth: 0.75) // Center line let centerLine = Path { path in path.move(to: CGPoint(x: 0, y: size.height / 2)) path.addLine(to: CGPoint(x: size.width, y: size.height / 2)) } context.stroke(centerLine, with: .color(Color.primary.opacity(0.1)), lineWidth: 0.5) } } // MARK: - Photo Track private var photoTrack: some View { ZStack(alignment: .leading) { Color.clear ForEach(photoEvents) { event in 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) .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10) } } } // MARK: - Note Track private var noteTrack: some View { ZStack(alignment: .leading) { Color.clear ForEach(noteEvents) { event in let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text" 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) .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10) } } } // MARK: - Playhead private var playhead: some View { let x = xPosition(for: currentTimeMs) return Rectangle() .fill(Color.primary) // Dynamic primary color for playhead line .frame(width: 1.0) .offset(x: x) .animation(.easeOut(duration: 0.15), value: 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: "笔记") } } 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) } // 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 = timeMs / totalDurationMs return padding + usableWidth * CGFloat(ratio) } private var photoEvents: [CelestiaTimelineEvent] { events.filter { $0.eventType == "PHOTO" } } private var noteEvents: [CelestiaTimelineEvent] { events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" } } } // MARK: - Preview #Preview { VStack { MultiTrackTimeline( events: [], currentTimeMs: .constant(30000), totalDurationMs: 120000 ) .frame(height: 140) .padding() } .background(Color.spaceBlack) }