WaveformView.swift 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import SwiftUI
  2. /// Interactive waveform display with playback progress and seek.
  3. struct WaveformView: View {
  4. let samples: [WaveformGenerator.WaveformSample]
  5. let progress: Double
  6. let isLoading: Bool
  7. let onSeek: (Double) -> Void
  8. @State private var hoveredProgress: Double?
  9. @State private var isDragging = false
  10. var body: some View {
  11. GeometryReader { geometry in
  12. ZStack {
  13. if isLoading {
  14. ProgressView("Generating waveform...")
  15. .font(.caption)
  16. } else if samples.isEmpty {
  17. RoundedRectangle(cornerRadius: 4)
  18. .fill(.quaternary)
  19. } else {
  20. // Waveform canvas
  21. Canvas { context, size in
  22. drawWaveform(context: context, size: size)
  23. }
  24. .gesture(waveformGesture(width: geometry.size.width))
  25. .onHover { hovering in
  26. if !hovering { hoveredProgress = nil }
  27. }
  28. .onContinuousHover { phase in
  29. switch phase {
  30. case .active(let location):
  31. hoveredProgress = location.x / geometry.size.width
  32. case .ended:
  33. hoveredProgress = nil
  34. @unknown default:
  35. break
  36. }
  37. }
  38. // Hover indicator
  39. if let hovered = hoveredProgress, !isDragging {
  40. Rectangle()
  41. .fill(.white.opacity(0.3))
  42. .frame(width: 1)
  43. .position(x: hovered * geometry.size.width, y: geometry.size.height / 2)
  44. .allowsHitTesting(false)
  45. }
  46. }
  47. }
  48. .clipShape(RoundedRectangle(cornerRadius: 6))
  49. .background(
  50. RoundedRectangle(cornerRadius: 6)
  51. .fill(.black.opacity(0.3))
  52. )
  53. }
  54. }
  55. // MARK: - Drawing
  56. private func drawWaveform(context: GraphicsContext, size: CGSize) {
  57. let width = size.width
  58. let height = size.height
  59. let midY = height / 2
  60. let count = samples.count
  61. guard count > 0 else { return }
  62. let playedIndex = Int(progress * Double(count))
  63. // Draw each column
  64. for (index, sample) in samples.enumerated() {
  65. let x = CGFloat(index) / CGFloat(count) * width
  66. let barWidth = max(1, width / CGFloat(count))
  67. let maxHeight = CGFloat(sample.max) * midY
  68. let minHeight = CGFloat(sample.min) * midY
  69. let rect = CGRect(
  70. x: x,
  71. y: midY - maxHeight,
  72. width: barWidth,
  73. height: maxHeight - minHeight
  74. )
  75. let color: Color
  76. if index < playedIndex {
  77. color = .accentColor
  78. } else {
  79. color = .gray.opacity(0.5)
  80. }
  81. context.fill(Path(rect), with: .color(color))
  82. }
  83. // Playhead line
  84. let playheadX = progress * width
  85. var playheadPath = Path()
  86. playheadPath.move(to: CGPoint(x: playheadX, y: 0))
  87. playheadPath.addLine(to: CGPoint(x: playheadX, y: height))
  88. context.stroke(playheadPath, with: .color(.white), lineWidth: 1.5)
  89. }
  90. // MARK: - Gesture
  91. private func waveformGesture(width: CGFloat) -> some Gesture {
  92. DragGesture(minimumDistance: 0)
  93. .onChanged { value in
  94. isDragging = true
  95. let prog = max(0, min(1, value.location.x / width))
  96. onSeek(prog)
  97. }
  98. .onEnded { _ in
  99. isDragging = false
  100. }
  101. }
  102. }