MultiTrackTimeline.swift 14 KB

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