MultiTrackTimeline.swift 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import SwiftUI
  2. // MARK: - MultiTrackTimeline
  3. /// A three-track horizontal timeline visualization showing:
  4. /// - Track 1: Audio availability and silence markers
  5. /// - Track 2: Photo markers (camera outlines at event positions)
  6. /// - Track 3: Note/marker outline icons at event positions
  7. /// Includes a time ruler and a central playhead cursor.
  8. /// Fully redesigned with a minimalist business line-drawn style.
  9. struct MultiTrackTimeline: View {
  10. let events: [CelestiaTimelineEvent]
  11. @Binding var currentTimeMs: Double
  12. let totalDurationMs: Double
  13. var silentRanges: [SilenceRange] = []
  14. /// Scale: points per second
  15. private let pointsPerSecond: CGFloat = 2.5
  16. /// Minimum timeline width
  17. private let minimumWidth: CGFloat = 400
  18. private var timelineWidth: CGFloat {
  19. max(minimumWidth, CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60)
  20. }
  21. var body: some View {
  22. VStack(spacing: 0) {
  23. ScrollViewReader { scrollProxy in
  24. ScrollView(.horizontal, showsIndicators: false) {
  25. ZStack(alignment: .topLeading) {
  26. // Background
  27. RoundedRectangle(cornerRadius: 10)
  28. .fill(Color.cardBackground.opacity(0.15))
  29. VStack(spacing: 0) {
  30. // Time Ruler
  31. timeRuler
  32. .frame(height: 26)
  33. // Track 1: Audio Waveform
  34. audioTrack
  35. .frame(height: 38)
  36. trackDivider
  37. // Track 2: Photo Markers
  38. photoTrack
  39. .frame(height: 28)
  40. trackDivider
  41. // Track 3: Note Markers
  42. noteTrack
  43. .frame(height: 28)
  44. }
  45. // Playhead Cursor
  46. playhead
  47. .id("playhead")
  48. }
  49. .frame(width: timelineWidth)
  50. }
  51. .clipShape(RoundedRectangle(cornerRadius: 10))
  52. .businessBorder(cornerRadius: 10)
  53. .onChange(of: currentTimeMs) { _, _ in
  54. withAnimation(.easeOut(duration: 0.2)) {
  55. scrollProxy.scrollTo("playhead", anchor: .center)
  56. }
  57. }
  58. }
  59. // Track labels
  60. trackLabels
  61. .padding(.top, 8)
  62. }
  63. }
  64. // MARK: - Time Ruler
  65. private var timeRuler: some View {
  66. Canvas { context, size in
  67. let totalSeconds = totalDurationMs / 1000.0
  68. let markerInterval: Double = totalSeconds > 300 ? 60 : (totalSeconds > 60 ? 10 : 5)
  69. var t: Double = 0
  70. while t <= totalSeconds {
  71. let x = xPosition(for: t * 1000)
  72. // Tick mark
  73. let tickPath = Path { path in
  74. path.move(to: CGPoint(x: x, y: size.height - 6))
  75. path.addLine(to: CGPoint(x: x, y: size.height))
  76. }
  77. context.stroke(tickPath, with: .color(Color.primary.opacity(0.2)), lineWidth: 1)
  78. // Time label
  79. let minutes = Int(t) / 60
  80. let seconds = Int(t) % 60
  81. let label = String(format: "%d:%02d", minutes, seconds)
  82. let text = Text(label)
  83. .font(.system(size: 8, weight: .regular, design: .monospaced))
  84. .foregroundStyle(Color.secondary.opacity(0.8))
  85. context.draw(
  86. context.resolve(text),
  87. at: CGPoint(x: x, y: size.height - 14),
  88. anchor: .center
  89. )
  90. t += markerInterval
  91. }
  92. }
  93. }
  94. // MARK: - Audio Track
  95. private var audioTrack: some View {
  96. ZStack {
  97. Rectangle()
  98. .fill(Color.primary.opacity(0.12))
  99. .frame(height: 1)
  100. ForEach(Array(silentRanges.enumerated()), id: \.offset) { _, range in
  101. Rectangle()
  102. .fill(Color.secondary.opacity(0.16))
  103. .frame(width: max(1, xPosition(for: range.end * 1000) - xPosition(for: range.start * 1000)))
  104. .offset(x: xPosition(for: range.start * 1000) - timelineWidth / 2)
  105. }
  106. }
  107. }
  108. // MARK: - Photo Track
  109. private var photoTrack: some View {
  110. ZStack(alignment: .leading) {
  111. Color.clear
  112. ForEach(photoEvents) { event in
  113. Image(systemName: "camera")
  114. .font(.system(size: 9))
  115. .foregroundStyle(Color.primary.opacity(0.8))
  116. .frame(width: 20, height: 20)
  117. .background(Color.cardBackground.opacity(0.6))
  118. .businessBorder(cornerRadius: 10)
  119. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  120. }
  121. }
  122. }
  123. // MARK: - Note Track
  124. private var noteTrack: some View {
  125. ZStack(alignment: .leading) {
  126. Color.clear
  127. ForEach(noteEvents) { event in
  128. let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text"
  129. Image(systemName: iconName)
  130. .font(.system(size: 9))
  131. .foregroundStyle(Color.primary.opacity(0.8))
  132. .frame(width: 20, height: 20)
  133. .background(Color.cardBackground.opacity(0.6))
  134. .businessBorder(cornerRadius: 10)
  135. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  136. }
  137. }
  138. }
  139. // MARK: - Playhead
  140. private var playhead: some View {
  141. let x = xPosition(for: currentTimeMs)
  142. return Rectangle()
  143. .fill(Color.primary) // Dynamic primary color for playhead line
  144. .frame(width: 1.0)
  145. .offset(x: x)
  146. .animation(.easeOut(duration: 0.15), value: currentTimeMs)
  147. }
  148. // MARK: - Track Divider
  149. private var trackDivider: some View {
  150. Rectangle()
  151. .fill(Color.lineBorder)
  152. .frame(height: 1)
  153. }
  154. // MARK: - Track Labels
  155. private var trackLabels: some View {
  156. HStack(spacing: 24) {
  157. trackLabel(icon: "waveform", text: "音频")
  158. trackLabel(icon: "camera", text: "图片")
  159. trackLabel(icon: "doc.text", text: "笔记")
  160. }
  161. }
  162. private func trackLabel(icon: String, text: String) -> some View {
  163. HStack(spacing: 4) {
  164. Image(systemName: icon)
  165. .font(.system(size: 9, weight: .light))
  166. Text(text)
  167. .font(.system(size: 9, weight: .regular))
  168. }
  169. .foregroundStyle(Color.secondary)
  170. }
  171. // MARK: - Helpers
  172. private func xPosition(for timeMs: Double) -> CGFloat {
  173. guard totalDurationMs > 0 else { return 30 }
  174. let padding: CGFloat = 30
  175. let usableWidth = timelineWidth - padding * 2
  176. let ratio = timeMs / totalDurationMs
  177. return padding + usableWidth * CGFloat(ratio)
  178. }
  179. private var photoEvents: [CelestiaTimelineEvent] {
  180. events.filter { $0.eventType == "PHOTO" }
  181. }
  182. private var noteEvents: [CelestiaTimelineEvent] {
  183. events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }
  184. }
  185. }
  186. // MARK: - Preview
  187. #Preview {
  188. VStack {
  189. MultiTrackTimeline(
  190. events: [],
  191. currentTimeMs: .constant(30000),
  192. totalDurationMs: 120000
  193. )
  194. .frame(height: 140)
  195. .padding()
  196. }
  197. .background(Color.spaceBlack)
  198. }