| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- 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..<sampleCount {
- 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
- 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)
- }
|