MultiTrackTimeline.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import SwiftUI
  2. // MARK: - MultiTrackTimeline
  3. /// A three-track horizontal timeline visualization showing:
  4. /// - Track 1: Audio waveform (mock sine wave outline)
  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. Canvas { context, size in
  97. let sampleCount = Int(size.width)
  98. guard sampleCount > 0 else { return }
  99. let waveformPath = Path { path in
  100. for i in 0..<sampleCount {
  101. let x = CGFloat(i)
  102. let progress = Double(i) / Double(sampleCount)
  103. let time = progress * (totalDurationMs / 1000.0)
  104. let isInSilence = silentRanges.contains(where: { time >= $0.start && time < $0.end })
  105. let amplitude: Double
  106. if isInSilence {
  107. amplitude = 0.02
  108. } else {
  109. amplitude = abs(
  110. sin(time * 3.0) * 0.45 +
  111. sin(time * 7.3) * 0.2 +
  112. sin(time * 13.7) * 0.1 +
  113. sin(time * 0.8) * 0.15
  114. )
  115. }
  116. let height = amplitude * Double(size.height * 0.7)
  117. let centerY = size.height / 2
  118. if i == 0 {
  119. path.move(to: CGPoint(x: x, y: centerY - height / 2))
  120. } else {
  121. path.addLine(to: CGPoint(x: x, y: centerY - height / 2))
  122. }
  123. }
  124. // Mirror back
  125. for i in stride(from: sampleCount - 1, through: 0, by: -1) {
  126. let x = CGFloat(i)
  127. let progress = Double(i) / Double(sampleCount)
  128. let time = progress * (totalDurationMs / 1000.0)
  129. let isInSilence = silentRanges.contains(where: { time >= $0.start && time < $0.end })
  130. let amplitude: Double
  131. if isInSilence {
  132. amplitude = 0.02
  133. } else {
  134. amplitude = abs(
  135. sin(time * 3.0) * 0.45 +
  136. sin(time * 7.3) * 0.2 +
  137. sin(time * 13.7) * 0.1 +
  138. sin(time * 0.8) * 0.15
  139. )
  140. }
  141. let height = amplitude * Double(size.height * 0.7)
  142. let centerY = size.height / 2
  143. path.addLine(to: CGPoint(x: x, y: centerY + height / 2))
  144. }
  145. path.closeSubpath()
  146. }
  147. // Fill with a very subtle neutral monochrome shade
  148. context.fill(waveformPath, with: .color(Color.primary.opacity(0.08)))
  149. // Draw clean outline
  150. context.stroke(waveformPath, with: .color(Color.primary.opacity(0.35)), lineWidth: 0.75)
  151. // Center line
  152. let centerLine = Path { path in
  153. path.move(to: CGPoint(x: 0, y: size.height / 2))
  154. path.addLine(to: CGPoint(x: size.width, y: size.height / 2))
  155. }
  156. context.stroke(centerLine, with: .color(Color.primary.opacity(0.1)), lineWidth: 0.5)
  157. }
  158. }
  159. // MARK: - Photo Track
  160. private var photoTrack: some View {
  161. ZStack(alignment: .leading) {
  162. Color.clear
  163. ForEach(photoEvents) { event in
  164. Image(systemName: "camera")
  165. .font(.system(size: 9))
  166. .foregroundStyle(Color.primary.opacity(0.8))
  167. .frame(width: 20, height: 20)
  168. .background(Color.cardBackground.opacity(0.6))
  169. .businessBorder(cornerRadius: 10)
  170. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  171. }
  172. }
  173. }
  174. // MARK: - Note Track
  175. private var noteTrack: some View {
  176. ZStack(alignment: .leading) {
  177. Color.clear
  178. ForEach(noteEvents) { event in
  179. let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text"
  180. Image(systemName: iconName)
  181. .font(.system(size: 9))
  182. .foregroundStyle(Color.primary.opacity(0.8))
  183. .frame(width: 20, height: 20)
  184. .background(Color.cardBackground.opacity(0.6))
  185. .businessBorder(cornerRadius: 10)
  186. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  187. }
  188. }
  189. }
  190. // MARK: - Playhead
  191. private var playhead: some View {
  192. let x = xPosition(for: currentTimeMs)
  193. return Rectangle()
  194. .fill(Color.primary) // Dynamic primary color for playhead line
  195. .frame(width: 1.0)
  196. .offset(x: x)
  197. .animation(.easeOut(duration: 0.15), value: currentTimeMs)
  198. }
  199. // MARK: - Track Divider
  200. private var trackDivider: some View {
  201. Rectangle()
  202. .fill(Color.lineBorder)
  203. .frame(height: 1)
  204. }
  205. // MARK: - Track Labels
  206. private var trackLabels: some View {
  207. HStack(spacing: 24) {
  208. trackLabel(icon: "waveform", text: "音频")
  209. trackLabel(icon: "camera", text: "图片")
  210. trackLabel(icon: "doc.text", text: "笔记")
  211. }
  212. }
  213. private func trackLabel(icon: String, text: String) -> some View {
  214. HStack(spacing: 4) {
  215. Image(systemName: icon)
  216. .font(.system(size: 9, weight: .light))
  217. Text(text)
  218. .font(.system(size: 9, weight: .regular))
  219. }
  220. .foregroundStyle(Color.secondary)
  221. }
  222. // MARK: - Helpers
  223. private func xPosition(for timeMs: Double) -> CGFloat {
  224. guard totalDurationMs > 0 else { return 30 }
  225. let padding: CGFloat = 30
  226. let usableWidth = timelineWidth - padding * 2
  227. let ratio = timeMs / totalDurationMs
  228. return padding + usableWidth * CGFloat(ratio)
  229. }
  230. private var photoEvents: [CelestiaTimelineEvent] {
  231. events.filter { $0.eventType == "PHOTO" }
  232. }
  233. private var noteEvents: [CelestiaTimelineEvent] {
  234. events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }
  235. }
  236. }
  237. // MARK: - Preview
  238. #Preview {
  239. VStack {
  240. MultiTrackTimeline(
  241. events: [],
  242. currentTimeMs: .constant(30000),
  243. totalDurationMs: 120000
  244. )
  245. .frame(height: 140)
  246. .padding()
  247. }
  248. .background(Color.spaceBlack)
  249. }