From 7cb4c5e86a3a3a9514745e42d10fd636f7904c24 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:38:15 +0200 Subject: [PATCH] Show editable segment window timeline --- Sources/GrowthLapse/ContentView.swift | 83 +++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index 1213201..bf80cd1 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -368,18 +368,19 @@ struct ContentView: View { } private func clipEditor(_ clip: GrowthLapseProjectClip) -> some View { - let maxStart = max(0, clip.duration - clip.segmentLength) + let liveClip = currentClip(id: clip.id) ?? clip + let maxStart = max(0, liveClip.duration - liveClip.segmentLength) let startBinding = Binding( get: { currentClip(id: clip.id)?.segmentStart ?? clip.segmentStart }, set: { processor.updateClip(clip, segmentStart: $0) } ) return VStack(alignment: .leading, spacing: 8) { - ClipPreviewView(clip: currentClip(id: clip.id) ?? clip) + ClipPreviewView(clip: liveClip) .frame(height: 180) .clipShape(RoundedRectangle(cornerRadius: 6)) - Text(clip.displayName) + Text(liveClip.displayName) .font(.subheadline.weight(.semibold)) .lineLimit(2) @@ -391,7 +392,16 @@ struct ContentView: View { .foregroundStyle(.secondary) } - Slider(value: startBinding, in: 0...maxStart, step: 0.1) + SegmentTimelineView(clip: liveClip) + .frame(height: 42) + .help("Blau ist das verwendete Segmentfenster. Der markierte rechte Bereich kann nicht als Startpunkt gewählt werden, weil das Segment sonst über das Videoende hinauslaufen würde.") + + if maxStart > 0 { + Slider(value: startBinding, in: 0...maxStart, step: 0.1) + } else { + Slider(value: .constant(0), in: 0...1) + .disabled(true) + } HStack { Button("-1 s") { @@ -401,7 +411,7 @@ struct ContentView: View { processor.updateClip(clip, segmentStart: startBinding.wrappedValue - 0.5) } Button("Zur Mitte") { - processor.updateClip(clip, segmentStart: max(0, (clip.duration - clip.segmentLength) / 2 + settings.startOffset)) + processor.updateClip(clip, segmentStart: max(0, (liveClip.duration - liveClip.segmentLength) / 2 + settings.startOffset)) } Button("+0,5 s") { processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 0.5) @@ -410,7 +420,7 @@ struct ContentView: View { processor.updateClip(clip, segmentStart: startBinding.wrappedValue + 1) } } - .disabled(processor.state.isRunning) + .disabled(processor.state.isRunning || maxStart <= 0) } .frame(maxWidth: .infinity, alignment: .topLeading) } @@ -554,3 +564,64 @@ private struct ClipPreviewView: View { player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) } } + +private struct SegmentTimelineView: View { + let clip: GrowthLapseProjectClip + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + GeometryReader { proxy in + let width = max(1, proxy.size.width) + let duration = max(0.001, clip.duration) + let maxStart = max(0, clip.duration - clip.segmentLength) + let startX = width * CGFloat(clamp(clip.segmentStart / duration)) + let segmentWidth = max(3, width * CGFloat(clamp(clip.segmentLength / duration))) + let invalidStartX = width * CGFloat(clamp(maxStart / duration)) + let invalidWidth = max(0, width - invalidStartX) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(nsColor: .separatorColor).opacity(0.35)) + + Rectangle() + .fill(Color.orange.opacity(0.16)) + .frame(width: invalidWidth) + .offset(x: invalidStartX) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.accentColor.opacity(0.75)) + .frame(width: min(segmentWidth, width - startX)) + .offset(x: startX) + + Rectangle() + .fill(Color.accentColor) + .frame(width: 2) + .offset(x: startX) + } + } + .frame(height: 14) + + HStack { + Text("0 s") + Spacer() + Text("Segment \(format(clip.segmentStart))-\(format(min(clip.duration, clip.segmentStart + clip.segmentLength))) s") + Spacer() + Text("\(format(clip.duration)) s") + } + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + private func clamp(_ value: Double) -> Double { + min(1, max(0, value)) + } + + private func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.minimumFractionDigits = value.rounded() == value ? 0 : 1 + formatter.maximumFractionDigits = 1 + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } +}