|
@@ -11,8 +11,11 @@ struct MultiTrackTimeline: View {
|
|
|
let events: [CelestiaTimelineEvent]
|
|
let events: [CelestiaTimelineEvent]
|
|
|
@Binding var currentTimeMs: Double
|
|
@Binding var currentTimeMs: Double
|
|
|
let totalDurationMs: Double
|
|
let totalDurationMs: Double
|
|
|
|
|
+ var waveformSamples: [Float] = []
|
|
|
var silentRanges: [SilenceRange] = []
|
|
var silentRanges: [SilenceRange] = []
|
|
|
|
|
|
|
|
|
|
+ @State private var playheadDragStartTimeMs: Double?
|
|
|
|
|
+
|
|
|
/// Scale: points per second
|
|
/// Scale: points per second
|
|
|
private let pointsPerSecond: CGFloat = 2.5
|
|
private let pointsPerSecond: CGFloat = 2.5
|
|
|
/// Minimum timeline width
|
|
/// Minimum timeline width
|
|
@@ -62,6 +65,7 @@ struct MultiTrackTimeline: View {
|
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
|
.businessBorder(cornerRadius: 10)
|
|
.businessBorder(cornerRadius: 10)
|
|
|
.onChange(of: currentTimeMs) { _, _ in
|
|
.onChange(of: currentTimeMs) { _, _ in
|
|
|
|
|
+ guard playheadDragStartTimeMs == nil else { return }
|
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
|
scrollProxy.scrollTo("playhead", anchor: .center)
|
|
scrollProxy.scrollTo("playhead", anchor: .center)
|
|
|
}
|
|
}
|
|
@@ -115,15 +119,64 @@ struct MultiTrackTimeline: View {
|
|
|
// MARK: - Audio Track
|
|
// MARK: - Audio Track
|
|
|
|
|
|
|
|
private var audioTrack: some View {
|
|
private var audioTrack: some View {
|
|
|
- ZStack {
|
|
|
|
|
- Rectangle()
|
|
|
|
|
- .fill(Color.primary.opacity(0.12))
|
|
|
|
|
- .frame(height: 1)
|
|
|
|
|
- ForEach(Array(silentRanges.enumerated()), id: \.offset) { _, range in
|
|
|
|
|
- Rectangle()
|
|
|
|
|
- .fill(Color.secondary.opacity(0.16))
|
|
|
|
|
- .frame(width: max(1, xPosition(for: range.end * 1000) - xPosition(for: range.start * 1000)))
|
|
|
|
|
- .offset(x: xPosition(for: range.start * 1000) - timelineWidth / 2)
|
|
|
|
|
|
|
+ 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))
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -169,11 +222,50 @@ struct MultiTrackTimeline: View {
|
|
|
|
|
|
|
|
private var playhead: some View {
|
|
private var playhead: some View {
|
|
|
let x = xPosition(for: currentTimeMs)
|
|
let x = xPosition(for: currentTimeMs)
|
|
|
- return Rectangle()
|
|
|
|
|
- .fill(Color.primary) // Dynamic primary color for playhead line
|
|
|
|
|
- .frame(width: 1.0)
|
|
|
|
|
- .offset(x: x)
|
|
|
|
|
- .animation(.easeOut(duration: 0.15), value: 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
|
|
// MARK: - Track Divider
|
|
@@ -210,10 +302,24 @@ struct MultiTrackTimeline: View {
|
|
|
guard totalDurationMs > 0 else { return 30 }
|
|
guard totalDurationMs > 0 else { return 30 }
|
|
|
let padding: CGFloat = 30
|
|
let padding: CGFloat = 30
|
|
|
let usableWidth = timelineWidth - padding * 2
|
|
let usableWidth = timelineWidth - padding * 2
|
|
|
- let ratio = timeMs / totalDurationMs
|
|
|
|
|
|
|
+ let ratio = min(max(timeMs / totalDurationMs, 0), 1)
|
|
|
return padding + usableWidth * CGFloat(ratio)
|
|
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] {
|
|
private var photoEvents: [CelestiaTimelineEvent] {
|
|
|
events.filter { $0.eventType == "PHOTO" }
|
|
events.filter { $0.eventType == "PHOTO" }
|
|
|
}
|
|
}
|
|
@@ -230,7 +336,8 @@ struct MultiTrackTimeline: View {
|
|
|
MultiTrackTimeline(
|
|
MultiTrackTimeline(
|
|
|
events: [],
|
|
events: [],
|
|
|
currentTimeMs: .constant(30000),
|
|
currentTimeMs: .constant(30000),
|
|
|
- totalDurationMs: 120000
|
|
|
|
|
|
|
+ totalDurationMs: 120000,
|
|
|
|
|
+ waveformSamples: [0.1, 0.3, 0.8, 0.4, 0.2]
|
|
|
)
|
|
)
|
|
|
.frame(height: 140)
|
|
.frame(height: 140)
|
|
|
.padding()
|
|
.padding()
|