MultiTrackTimeline.swift 15 KB

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