MultiTrackTimeline.swift 18 KB

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