| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- import SwiftUI
- // MARK: - MultiTrackTimeline
- /// A three-track horizontal timeline visualization showing:
- /// - Track 1: Audio availability and silence markers
- /// - Track 2: Photo markers (camera outlines at event positions)
- /// - Track 3: Note/marker outline icons at event positions
- /// Includes a time ruler and a central playhead cursor.
- /// Fully redesigned with a minimalist business line-drawn style.
- struct MultiTrackTimeline: View {
- let events: [CelestiaTimelineEvent]
- @Binding var currentTimeMs: Double
- let totalDurationMs: Double
- var waveformSamples: [Float] = []
- var silentRanges: [SilenceRange] = []
- @State private var playheadDragStartTimeMs: Double?
- /// Scale: points per second
- private let pointsPerSecond: CGFloat = 2.5
- /// Minimum timeline width
- private let minimumWidth: CGFloat = 400
- private var timelineWidth: CGFloat {
- max(minimumWidth, CGFloat(totalDurationMs / 1000.0) * pointsPerSecond + 60)
- }
- var body: some View {
- VStack(spacing: 0) {
- ScrollViewReader { scrollProxy in
- ScrollView(.horizontal, showsIndicators: false) {
- ZStack(alignment: .topLeading) {
- // Background
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.cardBackground.opacity(0.15))
- VStack(spacing: 0) {
- // Time Ruler
- timeRuler
- .frame(height: 26)
- // Track 1: Audio Waveform
- audioTrack
- .frame(height: 38)
- trackDivider
- // Track 2: Photo Markers
- photoTrack
- .frame(height: 28)
- trackDivider
- // Track 3: Note Markers
- noteTrack
- .frame(height: 28)
- }
- // Playhead Cursor
- playhead
- .id("playhead")
- }
- .frame(width: timelineWidth)
- }
- .clipShape(RoundedRectangle(cornerRadius: 10))
- .businessBorder(cornerRadius: 10)
- .onChange(of: currentTimeMs) { _, _ in
- guard playheadDragStartTimeMs == nil else { return }
- withAnimation(.easeOut(duration: 0.2)) {
- scrollProxy.scrollTo("playhead", anchor: .center)
- }
- }
- }
- // Track labels
- trackLabels
- .padding(.top, 8)
- }
- }
- // MARK: - Time Ruler
- private var timeRuler: some View {
- Canvas { context, size in
- let totalSeconds = totalDurationMs / 1000.0
- let markerInterval: Double = totalSeconds > 300 ? 60 : (totalSeconds > 60 ? 10 : 5)
- var t: Double = 0
- while t <= totalSeconds {
- let x = xPosition(for: t * 1000)
- // Tick mark
- let tickPath = Path { path in
- path.move(to: CGPoint(x: x, y: size.height - 6))
- path.addLine(to: CGPoint(x: x, y: size.height))
- }
- context.stroke(tickPath, with: .color(Color.primary.opacity(0.2)), lineWidth: 1)
- // Time label
- let minutes = Int(t) / 60
- let seconds = Int(t) % 60
- let label = String(format: "%d:%02d", minutes, seconds)
- let text = Text(label)
- .font(.system(size: 8, weight: .regular, design: .monospaced))
- .foregroundStyle(Color.secondary.opacity(0.8))
- context.draw(
- context.resolve(text),
- at: CGPoint(x: x, y: size.height - 14),
- anchor: .center
- )
- t += markerInterval
- }
- }
- }
- // MARK: - Audio Track
- private var audioTrack: some View {
- Canvas { context, size in
- let centerY = size.height / 2
- let barWidth: CGFloat = 1
- let barSpacing: CGFloat = 2
- let barSlot = barWidth + barSpacing
- let startX = xPosition(for: 0)
- let endX = xPosition(for: totalDurationMs)
- let waveformWidth = max(0, endX - startX)
- let barCount = max(0, Int(waveformWidth / barSlot))
- for range in silentRanges {
- let rangeStart = max(startX, xPosition(for: range.start * 1000))
- let rangeEnd = min(endX, xPosition(for: range.end * 1000))
- guard rangeEnd > rangeStart else { continue }
- context.fill(
- Path(CGRect(
- x: rangeStart,
- y: 0,
- width: rangeEnd - rangeStart,
- height: size.height
- )),
- with: .color(Color.secondary.opacity(0.10))
- )
- }
- let centerLine = Path { path in
- path.move(to: CGPoint(x: startX, y: centerY))
- path.addLine(to: CGPoint(x: endX, y: centerY))
- }
- context.stroke(
- centerLine,
- with: .color(Color.primary.opacity(0.1)),
- lineWidth: 1
- )
- guard barCount > 0, !waveformSamples.isEmpty else { return }
- for index in 0..<barCount {
- let sampleStart = index * waveformSamples.count / barCount
- let sampleEnd = max(
- sampleStart + 1,
- (index + 1) * waveformSamples.count / barCount
- )
- let upperBound = min(sampleEnd, waveformSamples.count)
- guard sampleStart < upperBound else { continue }
- let amplitude = waveformSamples[sampleStart..<upperBound].max() ?? 0
- let barHeight = max(CGFloat(max(amplitude, 0)) * size.height * 0.75, 1)
- let rect = CGRect(
- x: startX + CGFloat(index) * barSlot,
- y: centerY - barHeight / 2,
- width: barWidth,
- height: barHeight
- )
- context.fill(
- Path(rect),
- with: .color(Color.primary.opacity(0.85))
- )
- }
- }
- }
- // MARK: - Photo Track
- private var photoTrack: some View {
- ZStack(alignment: .leading) {
- Color.clear
- ForEach(photoEvents) { event in
- Image(systemName: "camera")
- .font(.system(size: 9))
- .foregroundStyle(Color.primary.opacity(0.8))
- .frame(width: 20, height: 20)
- .background(Color.cardBackground.opacity(0.6))
- .businessBorder(cornerRadius: 10)
- .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
- }
- }
- }
- // MARK: - Note Track
- private var noteTrack: some View {
- ZStack(alignment: .leading) {
- Color.clear
- ForEach(noteEvents) { event in
- let iconName = event.eventType == "MARKER" ? "exclamationmark.triangle" : "doc.text"
- Image(systemName: iconName)
- .font(.system(size: 9))
- .foregroundStyle(Color.primary.opacity(0.8))
- .frame(width: 20, height: 20)
- .background(Color.cardBackground.opacity(0.6))
- .businessBorder(cornerRadius: 10)
- .offset(x: xPosition(for: Double(event.relativeTimeMs)) - 10)
- }
- }
- }
- // MARK: - Playhead
- private var playhead: some View {
- let x = xPosition(for: currentTimeMs)
- return ZStack {
- Color.clear
- .contentShape(Rectangle())
- Rectangle()
- .fill(Color.primary)
- .frame(width: 1)
- }
- .frame(width: 28)
- .offset(x: x - 14)
- .animation(
- playheadDragStartTimeMs == nil ? .easeOut(duration: 0.15) : nil,
- value: currentTimeMs
- )
- .highPriorityGesture(playheadDragGesture)
- .accessibilityLabel("当前播放位置")
- .accessibilityValue(formattedTime(currentTimeMs))
- .accessibilityAdjustableAction { direction in
- let stepMs = 1_000.0
- switch direction {
- case .increment:
- currentTimeMs = min(totalDurationMs, currentTimeMs + stepMs)
- case .decrement:
- currentTimeMs = max(0, currentTimeMs - stepMs)
- @unknown default:
- break
- }
- }
- }
- private var playheadDragGesture: some Gesture {
- DragGesture(minimumDistance: 0, coordinateSpace: .global)
- .onChanged { value in
- let startTime = playheadDragStartTimeMs ?? currentTimeMs
- if playheadDragStartTimeMs == nil {
- playheadDragStartTimeMs = startTime
- }
- let startX = xPosition(for: startTime)
- currentTimeMs = timeMs(forXPosition: startX + value.translation.width)
- }
- .onEnded { _ in
- playheadDragStartTimeMs = nil
- }
- }
- // MARK: - Track Divider
- private var trackDivider: some View {
- Rectangle()
- .fill(Color.lineBorder)
- .frame(height: 1)
- }
- // MARK: - Track Labels
- private var trackLabels: some View {
- HStack(spacing: 24) {
- trackLabel(icon: "waveform", text: "音频")
- trackLabel(icon: "camera", text: "图片")
- trackLabel(icon: "doc.text", text: "笔记")
- }
- }
- private func trackLabel(icon: String, text: String) -> some View {
- HStack(spacing: 4) {
- Image(systemName: icon)
- .font(.system(size: 9, weight: .light))
- Text(text)
- .font(.system(size: 9, weight: .regular))
- }
- .foregroundStyle(Color.secondary)
- }
- // MARK: - Helpers
- private func xPosition(for timeMs: Double) -> CGFloat {
- guard totalDurationMs > 0 else { return 30 }
- let padding: CGFloat = 30
- let usableWidth = timelineWidth - padding * 2
- let ratio = min(max(timeMs / totalDurationMs, 0), 1)
- return padding + usableWidth * CGFloat(ratio)
- }
- private func timeMs(forXPosition x: CGFloat) -> Double {
- guard totalDurationMs > 0 else { return 0 }
- let padding: CGFloat = 30
- let usableWidth = timelineWidth - padding * 2
- guard usableWidth > 0 else { return 0 }
- let ratio = min(max((x - padding) / usableWidth, 0), 1)
- return Double(ratio) * totalDurationMs
- }
- private func formattedTime(_ timeMs: Double) -> String {
- let totalSeconds = max(0, Int(timeMs / 1_000))
- return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
- }
- private var photoEvents: [CelestiaTimelineEvent] {
- events.filter { $0.eventType == "PHOTO" }
- }
- private var noteEvents: [CelestiaTimelineEvent] {
- events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }
- }
- }
- // MARK: - Preview
- #Preview {
- VStack {
- MultiTrackTimeline(
- events: [],
- currentTimeMs: .constant(30000),
- totalDurationMs: 120000,
- waveformSamples: [0.1, 0.3, 0.8, 0.4, 0.2]
- )
- .frame(height: 140)
- .padding()
- }
- .background(Color.spaceBlack)
- }
|