MultiTrackTimeline.swift 17 KB

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