MultiTrackTimeline.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  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 waveformRevision: Int = 0
  24. var silentRanges: [SilenceRange] = []
  25. var onEmptyTrackTap: ((EditableTrack, Double) -> Void)?
  26. var onEventTap: ((CelestiaTimelineEvent) -> Void)?
  27. var onScrubBegan: (() -> Void)?
  28. var onScrubEnded: ((Double) -> Void)?
  29. @State private var playheadDragStartTimeMs: Double?
  30. @State private var lastAutoScrollTimeMs = -Double.greatestFiniteMagnitude
  31. @State private var viewportWidth: CGFloat = 0
  32. @State private var zoomScale: CGFloat = 1
  33. @GestureState private var gestureMagnification: CGFloat = 1
  34. /// Scale: points per second
  35. private let pointsPerSecond: CGFloat = 2.5
  36. private let maximumZoomScale: CGFloat = 8
  37. /// Matches the live-recording waveform height.
  38. private let audioTrackHeight: CGFloat = 90
  39. private let eventTrackHeight: CGFloat = 34
  40. private var timelineWidth: CGFloat {
  41. let durationWidth = CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60
  42. return max(viewportWidth, durationWidth) * effectiveZoomScale
  43. }
  44. private var effectiveZoomScale: CGFloat {
  45. min(max(zoomScale * gestureMagnification, 1), maximumZoomScale)
  46. }
  47. var body: some View {
  48. VStack(spacing: 0) {
  49. ScrollViewReader { scrollProxy in
  50. ScrollView(.horizontal, showsIndicators: false) {
  51. ZStack(alignment: .topLeading) {
  52. // Background
  53. RoundedRectangle(cornerRadius: 10)
  54. .fill(Color.cardBackground.opacity(0.15))
  55. VStack(spacing: 0) {
  56. // Time Ruler
  57. timeRuler
  58. .frame(height: 28)
  59. // Track 1: Audio Waveform
  60. audioTrack
  61. .frame(height: audioTrackHeight)
  62. trackDivider
  63. // Track 2: Photo Markers
  64. photoTrack
  65. .frame(height: eventTrackHeight)
  66. trackDivider
  67. // Track 3: Note Markers
  68. noteTrack
  69. .frame(height: eventTrackHeight)
  70. }
  71. // Playhead Cursor
  72. playhead
  73. .id("playhead")
  74. // Event hit targets stay above the draggable playhead so
  75. // overlapping photo and note markers remain tappable.
  76. eventHitTargets
  77. }
  78. .frame(width: timelineWidth)
  79. }
  80. .clipShape(RoundedRectangle(cornerRadius: 10))
  81. .businessBorder(cornerRadius: 10)
  82. .background {
  83. GeometryReader { geometry in
  84. Color.clear
  85. .onAppear {
  86. viewportWidth = geometry.size.width
  87. }
  88. .onChange(of: geometry.size.width) { _, newWidth in
  89. viewportWidth = newWidth
  90. }
  91. }
  92. }
  93. .simultaneousGesture(timelineMagnificationGesture)
  94. .onChange(of: currentTimeMs) { _, newValue in
  95. guard playheadDragStartTimeMs == nil else { return }
  96. guard abs(newValue - lastAutoScrollTimeMs) >= 750
  97. || newValue <= 0
  98. || newValue >= totalDurationMs else {
  99. return
  100. }
  101. lastAutoScrollTimeMs = newValue
  102. withAnimation(.easeOut(duration: 0.2)) {
  103. scrollProxy.scrollTo("playhead", anchor: .center)
  104. }
  105. }
  106. }
  107. // Track labels
  108. trackLabels
  109. .padding(.top, 8)
  110. }
  111. }
  112. private var timelineMagnificationGesture: some Gesture {
  113. MagnifyGesture()
  114. .updating($gestureMagnification) { value, state, _ in
  115. state = value.magnification
  116. }
  117. .onEnded { value in
  118. zoomScale = min(
  119. max(zoomScale * value.magnification, 1),
  120. maximumZoomScale
  121. )
  122. }
  123. }
  124. // MARK: - Time Ruler
  125. private var timeRuler: some View {
  126. Canvas { context, size in
  127. let totalSeconds = totalDurationMs / 1000.0
  128. let markerInterval: Double = totalSeconds > 300 ? 60 : (totalSeconds > 60 ? 10 : 5)
  129. var t: Double = 0
  130. while t <= totalSeconds {
  131. let x = xPosition(for: t * 1000)
  132. // Tick mark
  133. let tickPath = Path { path in
  134. path.move(to: CGPoint(x: x, y: size.height - 6))
  135. path.addLine(to: CGPoint(x: x, y: size.height))
  136. }
  137. context.stroke(tickPath, with: .color(Color.primary.opacity(0.2)), lineWidth: 1)
  138. // Time label
  139. let minutes = Int(t) / 60
  140. let seconds = Int(t) % 60
  141. let label = String(format: "%d:%02d", minutes, seconds)
  142. let text = Text(label)
  143. .font(.system(size: 8, weight: .regular, design: .monospaced))
  144. .foregroundStyle(Color.secondary.opacity(0.8))
  145. context.draw(
  146. context.resolve(text),
  147. at: CGPoint(x: x, y: size.height - 14),
  148. anchor: .center
  149. )
  150. t += markerInterval
  151. }
  152. }
  153. }
  154. // MARK: - Audio Track
  155. private var audioTrack: some View {
  156. ZStack(alignment: .leading) {
  157. PlaybackWaveformCanvas(
  158. totalDurationMs: totalDurationMs,
  159. waveformSamples: waveformSamples,
  160. silentRanges: silentRanges,
  161. revision: waveformRevision
  162. )
  163. .equatable()
  164. trackLeadingIcon(systemName: "waveform", accessibilityLabel: "音频轨道")
  165. ForEach(continuationEvents) { event in
  166. Button {
  167. onEventTap?(event)
  168. } label: {
  169. ZStack {
  170. Rectangle()
  171. .fill(Color.primary.opacity(0.35))
  172. .frame(width: 1, height: audioTrackHeight)
  173. Image(systemName: "record.circle")
  174. .font(.system(size: 11, weight: .medium))
  175. .foregroundStyle(Color.primary)
  176. .frame(width: 20, height: 20)
  177. .background(Color.cardBackground.opacity(0.9))
  178. .businessBorder(cornerRadius: 10)
  179. }
  180. .frame(width: 20, height: audioTrackHeight)
  181. }
  182. .buttonStyle(.plain)
  183. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  184. .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的续录信息")
  185. }
  186. }
  187. }
  188. // MARK: - Photo Track
  189. private var photoTrack: some View {
  190. ZStack(alignment: .leading) {
  191. emptyTrackTapTarget(for: .photo)
  192. trackLeadingIcon(systemName: "camera", accessibilityLabel: "图片轨道")
  193. ForEach(photoEventGroups) { group in
  194. Button {
  195. onEventTap?(group.events[0])
  196. } label: {
  197. ZStack(alignment: .topTrailing) {
  198. Image(systemName: "camera")
  199. .font(.system(size: 9))
  200. .foregroundStyle(Color.primary.opacity(0.8))
  201. .frame(width: 20, height: 20)
  202. .background(Color.cardBackground.opacity(0.6))
  203. .businessBorder(cornerRadius: 10)
  204. if group.events.count > 1 {
  205. Text("\(group.events.count)")
  206. .font(.system(size: 7, weight: .bold, design: .rounded))
  207. .foregroundStyle(Color.spaceBlack)
  208. .frame(minWidth: 13, minHeight: 13)
  209. .background(Color.primary, in: Circle())
  210. .offset(x: 5, y: -5)
  211. }
  212. }
  213. }
  214. .buttonStyle(.plain)
  215. .offset(x: xPosition(for: Double(group.relativeTimeMs)) - 10)
  216. .accessibilityLabel(
  217. "查看 \(group.events[0].relativeTimeFormatted) 的 \(group.events.count) 张图片"
  218. )
  219. }
  220. }
  221. }
  222. // MARK: - Note Track
  223. private var noteTrack: some View {
  224. ZStack(alignment: .leading) {
  225. emptyTrackTapTarget(for: .note)
  226. trackLeadingIcon(systemName: "doc.text", accessibilityLabel: "笔记轨道")
  227. ForEach(noteEvents) { event in
  228. let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text"
  229. Button {
  230. onEventTap?(event)
  231. } label: {
  232. Image(systemName: iconName)
  233. .font(.system(size: 9))
  234. .foregroundStyle(Color.primary.opacity(0.8))
  235. .frame(width: 20, height: 20)
  236. .background(Color.cardBackground.opacity(0.6))
  237. .businessBorder(cornerRadius: 10)
  238. }
  239. .buttonStyle(.plain)
  240. .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
  241. .accessibilityLabel("查看 \(event.relativeTimeFormatted) 的笔记")
  242. }
  243. }
  244. }
  245. // MARK: - Playhead
  246. private var eventHitTargets: some View {
  247. ZStack(alignment: .topLeading) {
  248. ForEach(photoEventGroups) { group in
  249. Button {
  250. onEventTap?(group.events[0])
  251. } label: {
  252. Color.clear
  253. .contentShape(Rectangle())
  254. .frame(width: 28, height: eventTrackHeight)
  255. }
  256. .buttonStyle(.plain)
  257. .offset(
  258. x: xPosition(for: Double(group.relativeTimeMs)) - 14,
  259. y: 28 + audioTrackHeight + 1
  260. )
  261. .accessibilityHidden(true)
  262. }
  263. ForEach(noteEvents) { event in
  264. Button {
  265. onEventTap?(event)
  266. } label: {
  267. Color.clear
  268. .contentShape(Rectangle())
  269. .frame(width: 28, height: eventTrackHeight)
  270. }
  271. .buttonStyle(.plain)
  272. .offset(
  273. x: xPosition(for: Double(event.relativeTimeMs)) - 14,
  274. y: 28 + audioTrackHeight + 1 + eventTrackHeight + 1
  275. )
  276. .accessibilityHidden(true)
  277. }
  278. }
  279. .frame(
  280. width: timelineWidth,
  281. height: 28 + audioTrackHeight + 2 + eventTrackHeight * 2,
  282. alignment: .topLeading
  283. )
  284. }
  285. private var playhead: some View {
  286. let x = xPosition(for: currentTimeMs)
  287. return ZStack {
  288. Color.clear
  289. .contentShape(Rectangle())
  290. Rectangle()
  291. .fill(Color.primary)
  292. .frame(width: 1)
  293. }
  294. .frame(width: 28)
  295. .offset(x: x - 14)
  296. .animation(
  297. playheadDragStartTimeMs == nil ? .easeOut(duration: 0.15) : nil,
  298. value: currentTimeMs
  299. )
  300. .highPriorityGesture(playheadDragGesture)
  301. .accessibilityLabel("当前播放位置")
  302. .accessibilityValue(formattedTime(currentTimeMs))
  303. .accessibilityAdjustableAction { direction in
  304. let stepMs = 1_000.0
  305. onScrubBegan?()
  306. switch direction {
  307. case .increment:
  308. currentTimeMs = min(totalDurationMs, currentTimeMs + stepMs)
  309. case .decrement:
  310. currentTimeMs = max(0, currentTimeMs - stepMs)
  311. @unknown default:
  312. break
  313. }
  314. onScrubEnded?(currentTimeMs)
  315. }
  316. }
  317. private var playheadDragGesture: some Gesture {
  318. DragGesture(minimumDistance: 0, coordinateSpace: .global)
  319. .onChanged { value in
  320. let startTime = playheadDragStartTimeMs ?? currentTimeMs
  321. if playheadDragStartTimeMs == nil {
  322. playheadDragStartTimeMs = startTime
  323. onScrubBegan?()
  324. }
  325. let startX = xPosition(for: startTime)
  326. currentTimeMs = timeMs(forXPosition: startX + value.translation.width)
  327. }
  328. .onEnded { _ in
  329. playheadDragStartTimeMs = nil
  330. onScrubEnded?(currentTimeMs)
  331. }
  332. }
  333. // MARK: - Track Divider
  334. private var trackDivider: some View {
  335. Rectangle()
  336. .fill(Color.lineBorder)
  337. .frame(height: 1)
  338. }
  339. // MARK: - Track Labels
  340. private var trackLabels: some View {
  341. HStack(spacing: 24) {
  342. trackLabel(icon: "waveform", text: "音频")
  343. trackLabel(icon: "camera", text: "图片")
  344. trackLabel(icon: "doc.text", text: "笔记")
  345. if !continuationEvents.isEmpty {
  346. trackLabel(icon: "record.circle", text: "续录")
  347. }
  348. }
  349. }
  350. private func trackLabel(icon: String, text: String) -> some View {
  351. HStack(spacing: 4) {
  352. Image(systemName: icon)
  353. .font(.system(size: 9, weight: .light))
  354. Text(text)
  355. .font(.system(size: 9, weight: .regular))
  356. }
  357. .foregroundStyle(Color.secondary)
  358. }
  359. private func trackLeadingIcon(
  360. systemName: String,
  361. accessibilityLabel: String
  362. ) -> some View {
  363. Image(systemName: systemName)
  364. .font(.system(size: 10, weight: .regular))
  365. .foregroundStyle(Color.secondary.opacity(0.85))
  366. .frame(width: 24)
  367. .accessibilityLabel(accessibilityLabel)
  368. }
  369. private func emptyTrackTapTarget(for track: EditableTrack) -> some View {
  370. Color.clear
  371. .contentShape(Rectangle())
  372. .gesture(
  373. SpatialTapGesture()
  374. .onEnded { value in
  375. let timeMs = timeMs(forXPosition: value.location.x)
  376. onEmptyTrackTap?(track, timeMs)
  377. }
  378. )
  379. }
  380. // MARK: - Helpers
  381. private func xPosition(for timeMs: Double) -> CGFloat {
  382. guard totalDurationMs > 0 else { return 30 }
  383. let padding: CGFloat = 30
  384. let usableWidth = timelineWidth - padding * 2
  385. let ratio = min(max(timeMs / totalDurationMs, 0), 1)
  386. return padding + usableWidth * CGFloat(ratio)
  387. }
  388. private func timeMs(forXPosition x: CGFloat) -> Double {
  389. guard totalDurationMs > 0 else { return 0 }
  390. let padding: CGFloat = 30
  391. let usableWidth = timelineWidth - padding * 2
  392. guard usableWidth > 0 else { return 0 }
  393. let ratio = min(max((x - padding) / usableWidth, 0), 1)
  394. return Double(ratio) * totalDurationMs
  395. }
  396. private func formattedTime(_ timeMs: Double) -> String {
  397. let totalSeconds = max(0, Int(timeMs / 1_000))
  398. return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
  399. }
  400. private var photoEvents: [CelestiaTimelineEvent] {
  401. events.filter { $0.eventType == "PHOTO" }
  402. }
  403. private var photoEventGroups: [PhotoEventGroup] {
  404. Dictionary(grouping: photoEvents, by: \.relativeTimeMs)
  405. .map { relativeTimeMs, events in
  406. PhotoEventGroup(
  407. relativeTimeMs: relativeTimeMs,
  408. events: events.sorted {
  409. if $0.createdAt == $1.createdAt {
  410. return $0.id.uuidString < $1.id.uuidString
  411. }
  412. return $0.createdAt < $1.createdAt
  413. }
  414. )
  415. }
  416. .sorted { $0.relativeTimeMs < $1.relativeTimeMs }
  417. }
  418. private var noteEvents: [CelestiaTimelineEvent] {
  419. events.filter {
  420. $0.eventType == "NOTE"
  421. || ($0.eventType == "MARKER" && !$0.isContinuationMarker)
  422. }
  423. }
  424. private var continuationEvents: [CelestiaTimelineEvent] {
  425. events.filter(\.isContinuationMarker)
  426. }
  427. }
  428. /// The expensive waveform aggregation is isolated from the frequently moving
  429. /// playhead. Its equality check uses the view-model revision instead of
  430. /// comparing every sample on each 100ms playback update.
  431. private struct PlaybackWaveformCanvas: View, Equatable {
  432. let totalDurationMs: Double
  433. let waveformSamples: [Float]
  434. let silentRanges: [SilenceRange]
  435. let revision: Int
  436. static func == (lhs: Self, rhs: Self) -> Bool {
  437. lhs.revision == rhs.revision
  438. && lhs.totalDurationMs == rhs.totalDurationMs
  439. }
  440. var body: some View {
  441. Canvas { context, size in
  442. let centerY = size.height / 2
  443. let barWidth: CGFloat = 1
  444. let barSpacing: CGFloat = 2
  445. let barSlot = barWidth + barSpacing
  446. let startX: CGFloat = 30
  447. let endX = max(startX, size.width - 30)
  448. let waveformWidth = max(0, endX - startX)
  449. let barCount = max(0, Int(waveformWidth / barSlot))
  450. for range in silentRanges {
  451. guard totalDurationMs > 0 else { continue }
  452. let rangeStart = startX + waveformWidth
  453. * CGFloat(max(0, range.start * 1_000) / totalDurationMs)
  454. let rangeEnd = startX + waveformWidth
  455. * CGFloat(min(totalDurationMs, range.end * 1_000) / totalDurationMs)
  456. guard rangeEnd > rangeStart else { continue }
  457. context.fill(
  458. Path(CGRect(
  459. x: rangeStart,
  460. y: 0,
  461. width: rangeEnd - rangeStart,
  462. height: size.height
  463. )),
  464. with: .color(Color.secondary.opacity(0.10))
  465. )
  466. }
  467. let centerLine = Path { path in
  468. path.move(to: CGPoint(x: startX, y: centerY))
  469. path.addLine(to: CGPoint(x: endX, y: centerY))
  470. }
  471. context.stroke(
  472. centerLine,
  473. with: .color(Color.primary.opacity(0.1)),
  474. lineWidth: 1
  475. )
  476. guard barCount > 0, !waveformSamples.isEmpty else { return }
  477. let envelopes = WaveformMinMaxDownsampler.envelopes(
  478. waveformSamples,
  479. targetBucketCount: barCount
  480. )
  481. guard !envelopes.isEmpty else { return }
  482. let bucketWidth = waveformWidth / CGFloat(envelopes.count)
  483. for (index, envelope) in envelopes.enumerated() {
  484. let maximumHeight = max(
  485. CGFloat(max(envelope.maximum, 0)) * size.height * 0.75,
  486. 1
  487. )
  488. let x = startX + CGFloat(index) * bucketWidth
  489. context.fill(
  490. Path(CGRect(
  491. x: x,
  492. y: centerY - maximumHeight / 2,
  493. width: min(barWidth, bucketWidth),
  494. height: maximumHeight
  495. )),
  496. with: .color(Color.primary.opacity(0.58))
  497. )
  498. let minimumHeight = CGFloat(max(envelope.minimum, 0)) * size.height * 0.75
  499. if minimumHeight >= 1 {
  500. context.fill(
  501. Path(CGRect(
  502. x: x,
  503. y: centerY - minimumHeight / 2,
  504. width: min(barWidth, bucketWidth),
  505. height: minimumHeight
  506. )),
  507. with: .color(Color.primary.opacity(0.9))
  508. )
  509. }
  510. }
  511. }
  512. }
  513. }
  514. private struct WaveformEnvelope {
  515. let minimum: Float
  516. let maximum: Float
  517. }
  518. /// Reduces an arbitrarily long recording to at most one min/max envelope per
  519. /// drawable screen bucket. Peaks survive zooming and scrolling without
  520. /// creating one SwiftUI element (or one Canvas draw) per source sample.
  521. private enum WaveformMinMaxDownsampler {
  522. static func envelopes(
  523. _ samples: [Float],
  524. targetBucketCount: Int
  525. ) -> [WaveformEnvelope] {
  526. guard !samples.isEmpty, targetBucketCount > 0 else { return [] }
  527. let bucketCount = min(samples.count, targetBucketCount)
  528. var result: [WaveformEnvelope] = []
  529. result.reserveCapacity(bucketCount)
  530. for bucket in 0..<bucketCount {
  531. let lowerBound = bucket * samples.count / bucketCount
  532. let upperBound = max(
  533. lowerBound + 1,
  534. (bucket + 1) * samples.count / bucketCount
  535. )
  536. var minimum = Float.greatestFiniteMagnitude
  537. var maximum = -Float.greatestFiniteMagnitude
  538. for sample in samples[lowerBound..<min(upperBound, samples.count)] {
  539. minimum = min(minimum, sample)
  540. maximum = max(maximum, sample)
  541. }
  542. result.append(
  543. WaveformEnvelope(
  544. minimum: minimum.isFinite ? minimum : 0,
  545. maximum: maximum.isFinite ? maximum : 0
  546. )
  547. )
  548. }
  549. return result
  550. }
  551. }
  552. // MARK: - Preview
  553. #Preview {
  554. VStack {
  555. MultiTrackTimeline(
  556. events: [],
  557. currentTimeMs: .constant(30000),
  558. totalDurationMs: 120000,
  559. waveformSamples: [0.1, 0.3, 0.8, 0.4, 0.2]
  560. )
  561. .frame(height: 140)
  562. .padding()
  563. }
  564. .background(Color.spaceBlack)
  565. }