MultiTrackTimeline.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  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 waveformSamples: [Float] = []
  14. var silentRanges: [SilenceRange] = []
  15. @State private var playheadDragStartTimeMs: Double?
  16. /// Scale: points per second
  17. private let pointsPerSecond: CGFloat = 2.5
  18. /// Minimum timeline width
  19. private let minimumWidth: CGFloat = 400
  20. private var timelineWidth: CGFloat {
  21. max(minimumWidth, CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60)
  22. }
  23. var body: some View {
  24. VStack(spacing: 0) {
  25. ScrollViewReader { scrollProxy in
  26. ScrollView(.horizontal, showsIndicators: false) {
  27. ZStack(alignment: .topLeading) {
  28. // Background
  29. RoundedRectangle(cornerRadius: 10)
  30. .fill(Color.cardBackground.opacity(0.15))
  31. VStack(spacing: 0) {
  32. // Time Ruler
  33. timeRuler
  34. .frame(height: 26)
  35. // Track 1: Audio Waveform
  36. audioTrack
  37. .frame(height: 38)
  38. trackDivider
  39. // Track 2: Photo Markers
  40. photoTrack
  41. .frame(height: 28)
  42. trackDivider
  43. // Track 3: Note Markers
  44. noteTrack
  45. .frame(height: 28)
  46. }
  47. // Playhead Cursor
  48. playhead
  49. .id("playhead")
  50. }
  51. .frame(width: timelineWidth)
  52. }
  53. .clipShape(RoundedRectangle(cornerRadius: 10))
  54. .businessBorder(cornerRadius: 10)
  55. .onChange(of: currentTimeMs) { _, _ in
  56. guard playheadDragStartTimeMs == nil else { return }
  57. withAnimation(.easeOut(duration: 0.2)) {
  58. scrollProxy.scrollTo("playhead", anchor: .center)
  59. }
  60. }
  61. }
  62. // Track labels
  63. trackLabels
  64. .padding(.top, 8)
  65. }
  66. }
  67. // MARK: - Time Ruler
  68. private var timeRuler: some View {
  69. Canvas { context, size in
  70. let totalSeconds = totalDurationMs / 1000.0
  71. let markerInterval: Double = totalSeconds > 300 ? 60 : (totalSeconds > 60 ? 10 : 5)
  72. var t: Double = 0
  73. while t <= totalSeconds {
  74. let x = xPosition(for: t * 1000)
  75. // Tick mark
  76. let tickPath = Path { path in
  77. path.move(to: CGPoint(x: x, y: size.height - 6))
  78. path.addLine(to: CGPoint(x: x, y: size.height))
  79. }
  80. context.stroke(tickPath, with: .color(Color.primary.opacity(0.2)), lineWidth: 1)
  81. // Time label
  82. let minutes = Int(t) / 60
  83. let seconds = Int(t) % 60
  84. let label = String(format: "%d:%02d", minutes, seconds)
  85. let text = Text(label)
  86. .font(.system(size: 8, weight: .regular, design: .monospaced))
  87. .foregroundStyle(Color.secondary.opacity(0.8))
  88. context.draw(
  89. context.resolve(text),
  90. at: CGPoint(x: x, y: size.height - 14),
  91. anchor: .center
  92. )
  93. t += markerInterval
  94. }
  95. }
  96. }
  97. // MARK: - Audio Track
  98. private var audioTrack: some View {
  99. Canvas { context, size in
  100. let centerY = size.height / 2
  101. let barWidth: CGFloat = 1
  102. let barSpacing: CGFloat = 2
  103. let barSlot = barWidth + barSpacing
  104. let startX = xPosition(for: 0)
  105. let endX = xPosition(for: totalDurationMs)
  106. let waveformWidth = max(0, endX - startX)
  107. let barCount = max(0, Int(waveformWidth / barSlot))
  108. for range in silentRanges {
  109. let rangeStart = max(startX, xPosition(for: range.start * 1000))
  110. let rangeEnd = min(endX, xPosition(for: range.end * 1000))
  111. guard rangeEnd > rangeStart else { continue }
  112. context.fill(
  113. Path(CGRect(
  114. x: rangeStart,
  115. y: 0,
  116. width: rangeEnd - rangeStart,
  117. height: size.height
  118. )),
  119. with: .color(Color.secondary.opacity(0.10))
  120. )
  121. }
  122. let centerLine = Path { path in
  123. path.move(to: CGPoint(x: startX, y: centerY))
  124. path.addLine(to: CGPoint(x: endX, y: centerY))
  125. }
  126. context.stroke(
  127. centerLine,
  128. with: .color(Color.primary.opacity(0.1)),
  129. lineWidth: 1
  130. )
  131. guard barCount > 0, !waveformSamples.isEmpty else { return }
  132. for index in 0..<barCount {
  133. let sampleStart = index * waveformSamples.count / barCount
  134. let sampleEnd = max(
  135. sampleStart + 1,
  136. (index + 1) * waveformSamples.count / barCount
  137. )
  138. let upperBound = min(sampleEnd, waveformSamples.count)
  139. guard sampleStart < upperBound else { continue }
  140. let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
  141. let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
  142. let rect = CGRect(
  143. x: startX + CGFloat(index) * barSlot,
  144. y: centerY - barHeight / 2,
  145. width: barWidth,
  146. height: barHeight
  147. )
  148. context.fill(
  149. Path(rect),
  150. with: .color(Color.primary.opacity(0.85))
  151. )
  152. }
  153. }
  154. }
  155. // MARK: - Photo Track
  156. private var photoTrack: some View {
  157. ZStack(alignment: .leading) {
  158. Color.clear
  159. ForEach(photoEvents) { event in
  160. Image(systemName: "camera")
  161. .font(.system(size: 9))
  162. .foregroundStyle(Color.primary.opacity(0.8))
  163. .frame(width: 20, height: 20)
  164. .background(Color.cardBackground.opacity(0.6))
  165. .businessBorder(cornerRadius: 10)
  166. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  167. }
  168. }
  169. }
  170. // MARK: - Note Track
  171. private var noteTrack: some View {
  172. ZStack(alignment: .leading) {
  173. Color.clear
  174. ForEach(noteEvents) { event in
  175. let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text"
  176. Image(systemName: iconName)
  177. .font(.system(size: 9))
  178. .foregroundStyle(Color.primary.opacity(0.8))
  179. .frame(width: 20, height: 20)
  180. .background(Color.cardBackground.opacity(0.6))
  181. .businessBorder(cornerRadius: 10)
  182. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  183. }
  184. }
  185. }
  186. // MARK: - Playhead
  187. private var playhead: some View {
  188. let x = xPosition(for: currentTimeMs)
  189. return ZStack {
  190. Color.clear
  191. .contentShape(Rectangle())
  192. Rectangle()
  193. .fill(Color.primary)
  194. .frame(width: 1)
  195. }
  196. .frame(width: 28)
  197. .offset(x: x - 14)
  198. .animation(
  199. playheadDragStartTimeMs == nil ? .easeOut(duration: 0.15) : nil,
  200. value: currentTimeMs
  201. )
  202. .highPriorityGesture(playheadDragGesture)
  203. .accessibilityLabel("当前播放位置")
  204. .accessibilityValue(formattedTime(currentTimeMs))
  205. .accessibilityAdjustableAction { direction in
  206. let stepMs = 1_000.0
  207. switch direction {
  208. case .increment:
  209. currentTimeMs = min(totalDurationMs, currentTimeMs + stepMs)
  210. case .decrement:
  211. currentTimeMs = max(0, currentTimeMs - stepMs)
  212. @unknown default:
  213. break
  214. }
  215. }
  216. }
  217. private var playheadDragGesture: some Gesture {
  218. DragGesture(minimumDistance: 0, coordinateSpace: .global)
  219. .onChanged { value in
  220. let startTime = playheadDragStartTimeMs ?? currentTimeMs
  221. if playheadDragStartTimeMs == nil {
  222. playheadDragStartTimeMs = startTime
  223. }
  224. let startX = xPosition(for: startTime)
  225. currentTimeMs = timeMs(forXPosition: startX + value.translation.width)
  226. }
  227. .onEnded { _ in
  228. playheadDragStartTimeMs = nil
  229. }
  230. }
  231. // MARK: - Track Divider
  232. private var trackDivider: some View {
  233. Rectangle()
  234. .fill(Color.lineBorder)
  235. .frame(height: 1)
  236. }
  237. // MARK: - Track Labels
  238. private var trackLabels: some View {
  239. HStack(spacing: 24) {
  240. trackLabel(icon: "waveform", text: "音频")
  241. trackLabel(icon: "camera", text: "图片")
  242. trackLabel(icon: "doc.text", text: "笔记")
  243. }
  244. }
  245. private func trackLabel(icon: String, text: String) -> some View {
  246. HStack(spacing: 4) {
  247. Image(systemName: icon)
  248. .font(.system(size: 9, weight: .light))
  249. Text(text)
  250. .font(.system(size: 9, weight: .regular))
  251. }
  252. .foregroundStyle(Color.secondary)
  253. }
  254. // MARK: - Helpers
  255. private func xPosition(for timeMs: Double) -> CGFloat {
  256. guard totalDurationMs > 0 else { return 30 }
  257. let padding: CGFloat = 30
  258. let usableWidth = timelineWidth - padding * 2
  259. let ratio = min(max(timeMs / totalDurationMs, 0), 1)
  260. return padding + usableWidth * CGFloat(ratio)
  261. }
  262. private func timeMs(forXPosition x: CGFloat) -> Double {
  263. guard totalDurationMs > 0 else { return 0 }
  264. let padding: CGFloat = 30
  265. let usableWidth = timelineWidth - padding * 2
  266. guard usableWidth > 0 else { return 0 }
  267. let ratio = min(max((x - padding) / usableWidth, 0), 1)
  268. return Double(ratio) * totalDurationMs
  269. }
  270. private func formattedTime(_ timeMs: Double) -> String {
  271. let totalSeconds = max(0, Int(timeMs / 1_000))
  272. return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
  273. }
  274. private var photoEvents: [CelestiaTimelineEvent] {
  275. events.filter { $0.eventType == "PHOTO" }
  276. }
  277. private var noteEvents: [CelestiaTimelineEvent] {
  278. events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }
  279. }
  280. }
  281. // MARK: - Preview
  282. #Preview {
  283. VStack {
  284. MultiTrackTimeline(
  285. events: [],
  286. currentTimeMs: .constant(30000),
  287. totalDurationMs: 120000,
  288. waveformSamples: [0.1, 0.3, 0.8, 0.4, 0.2]
  289. )
  290. .frame(height: 140)
  291. .padding()
  292. }
  293. .background(Color.spaceBlack)
  294. }