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 } let events: [CelestiaTimelineEvent] @Binding var currentTimeMs: Double let totalDurationMs: Double var waveformSamples: [Float] = [] var silentRanges: [SilenceRange] = [] var onEmptyTrackTap: ((EditableTrack, Double) -> Void)? var onEventTap: ((CelestiaTimelineEvent) -> Void)? @State private var playheadDragStartTimeMs: Double? /// Scale: points per second 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) } 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") } .frame(width: timelineWidth) } .clipShape(RoundedRectangle(cornerRadius: 10)) .businessBorder(cornerRadius: 10) .onChange(of: currentTimeMs) { _, _ in guard playheadDragStartTimeMs == nil else { return } 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 { ZStack(alignment: .leading) { Canvas { context, size in let centerY = size.height / 2 let barWidth: CGFloat = 1 let barSpacing: CGFloat = 2 let barSlot = barWidth + barSpacing let startX = xPosition(for: 0) let endX = xPosition(for: totalDurationMs) let waveformWidth = max(0, endX - startX) let barCount = max(0, Int(waveformWidth / barSlot)) for range in silentRanges { let rangeStart = max(startX, xPosition(for: range.start * 1000)) let rangeEnd = min(endX, xPosition(for: range.end * 1000)) 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 } for index in 0.. 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 noteEvents: [CelestiaTimelineEvent] { events.filter { $0.eventType == "NOTE" || ($0.eventType == "MARKER" && !$0.isContinuationMarker) } } private var continuationEvents: [CelestiaTimelineEvent] { events.filter(\.isContinuationMarker) } } // MARK: - Preview #Preview { VStack { MultiTrackTimeline( events: [], currentTimeMs: .constant(30000), totalDurationMs: 120000, waveformSamples: [0.1, 0.3, 0.8, 0.4, 0.2] ) .frame(height: 140) .padding() } .background(Color.spaceBlack) }