package ui import ( "image/color" "math" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/widget" ) // TimelineWidget shows video timeline with keyframe markers type TimelineWidget struct { widget.BaseWidget duration float64 // Total duration in seconds position float64 // Current position in seconds keyframes []float64 // Keyframe timestamps in seconds inPoint *float64 // In-point marker (for cuts) outPoint *float64 // Out-point marker (for cuts) onChange func(float64) // Callback when user seeks // Internal state dragging bool hovered bool // Cached rendering objects renderer *timelineRenderer } // NewTimeline creates a new timeline widget func NewTimeline(duration float64, onChange func(float64)) *TimelineWidget { t := &TimelineWidget{ duration: duration, position: 0, onChange: onChange, } t.ExtendBaseWidget(t) return t } // SetDuration updates the timeline duration func (t *TimelineWidget) SetDuration(duration float64) { t.duration = duration if t.renderer != nil { t.renderer.Refresh() } } // SetPosition updates the current playback position func (t *TimelineWidget) SetPosition(position float64) { t.position = position if t.renderer != nil { t.renderer.Refresh() } } // SetKeyframes sets the keyframe timestamps func (t *TimelineWidget) SetKeyframes(keyframes []float64) { t.keyframes = keyframes if t.renderer != nil { t.renderer.Refresh() } } // SetInPoint sets the in-point marker func (t *TimelineWidget) SetInPoint(position *float64) { t.inPoint = position if t.renderer != nil { t.renderer.Refresh() } } // SetOutPoint sets the out-point marker func (t *TimelineWidget) SetOutPoint(position *float64) { t.outPoint = position if t.renderer != nil { t.renderer.Refresh() } } // SetOnChange sets the callback function for position changes func (t *TimelineWidget) SetOnChange(callback func(float64)) { t.onChange = callback } // CreateRenderer implements fyne.Widget func (t *TimelineWidget) CreateRenderer() fyne.WidgetRenderer { t.renderer = &timelineRenderer{ timeline: t, } t.renderer.refresh() return t.renderer } // Tapped handles tap events func (t *TimelineWidget) Tapped(ev *fyne.PointEvent) { t.seekToPosition(ev.Position.X) } // TappedSecondary handles right-click (unused for now) func (t *TimelineWidget) TappedSecondary(*fyne.PointEvent) {} // Dragged handles drag events func (t *TimelineWidget) Dragged(ev *fyne.DragEvent) { t.dragging = true t.seekToPosition(ev.Position.X) } // DragEnd handles drag end func (t *TimelineWidget) DragEnd() { t.dragging = false } // MouseIn handles mouse enter func (t *TimelineWidget) MouseIn(*desktop.MouseEvent) { t.hovered = true } // MouseOut handles mouse leave func (t *TimelineWidget) MouseOut() { t.hovered = false } // MouseMoved handles mouse movement (unused for now) func (t *TimelineWidget) MouseMoved(*desktop.MouseEvent) {} // seekToPosition converts X coordinate to timeline position func (t *TimelineWidget) seekToPosition(x float32) { if t.duration <= 0 || t.Size().Width <= 0 { return } // Calculate position from X coordinate ratio := float64(x) / float64(t.Size().Width) ratio = math.Max(0, math.Min(1, ratio)) // Clamp to [0, 1] position := ratio * t.duration t.position = position if t.onChange != nil { t.onChange(position) } if t.renderer != nil { t.renderer.Refresh() } } // MinSize returns the minimum size for the timeline func (t *TimelineWidget) MinSize() fyne.Size { return fyne.NewSize(100, 30) } // timelineRenderer renders the timeline widget type timelineRenderer struct { timeline *TimelineWidget // Canvas objects background *canvas.Rectangle progressFill *canvas.Rectangle keyframeLines []*canvas.Line inPointLine *canvas.Line outPointLine *canvas.Line scrubberLine *canvas.Line scrubberCircle *canvas.Circle objects []fyne.CanvasObject } // Layout positions the timeline elements func (r *timelineRenderer) Layout(size fyne.Size) { if r.background != nil { r.background.Resize(size) } r.refresh() } // MinSize returns the minimum size func (r *timelineRenderer) MinSize() fyne.Size { return r.timeline.MinSize() } // Refresh updates the timeline display func (r *timelineRenderer) Refresh() { r.refresh() canvas.Refresh(r.timeline) } // refresh rebuilds the timeline visuals func (r *timelineRenderer) refresh() { t := r.timeline size := t.Size() if size.Width <= 0 || size.Height <= 0 { return } // Clear old objects r.objects = make([]fyne.CanvasObject, 0) // Background (dark gray) if r.background == nil { r.background = canvas.NewRectangle(color.NRGBA{R: 40, G: 40, B: 40, A: 255}) } r.background.Resize(size) r.objects = append(r.objects, r.background) // Progress fill (lighter gray showing played portion) if t.duration > 0 { progressRatio := float32(t.position / t.duration) progressWidth := size.Width * progressRatio if r.progressFill == nil { r.progressFill = canvas.NewRectangle(color.NRGBA{R: 60, G: 60, B: 60, A: 255}) } r.progressFill.Resize(fyne.NewSize(progressWidth, size.Height)) r.progressFill.Move(fyne.NewPos(0, 0)) r.objects = append(r.objects, r.progressFill) } // Keyframe markers (yellow vertical lines) if len(t.keyframes) > 0 && t.duration > 0 { r.keyframeLines = make([]*canvas.Line, 0, len(t.keyframes)) keyframeColor := color.NRGBA{R: 255, G: 220, B: 0, A: 180} // Yellow with transparency for _, kfTime := range t.keyframes { ratio := float32(kfTime / t.duration) x := ratio * size.Width line := canvas.NewLine(keyframeColor) line.StrokeWidth = 1 line.Position1 = fyne.NewPos(x, 0) line.Position2 = fyne.NewPos(x, size.Height) r.keyframeLines = append(r.keyframeLines, line) r.objects = append(r.objects, line) } } // In-point marker (blue vertical line) if t.inPoint != nil && t.duration > 0 { ratio := float32(*t.inPoint / t.duration) x := ratio * size.Width if r.inPointLine == nil { r.inPointLine = canvas.NewLine(color.NRGBA{R: 0, G: 120, B: 255, A: 255}) } r.inPointLine.StrokeWidth = 2 r.inPointLine.Position1 = fyne.NewPos(x, 0) r.inPointLine.Position2 = fyne.NewPos(x, size.Height) r.objects = append(r.objects, r.inPointLine) } // Out-point marker (red vertical line) if t.outPoint != nil && t.duration > 0 { ratio := float32(*t.outPoint / t.duration) x := ratio * size.Width if r.outPointLine == nil { r.outPointLine = canvas.NewLine(color.NRGBA{R: 255, G: 60, B: 60, A: 255}) } r.outPointLine.StrokeWidth = 2 r.outPointLine.Position1 = fyne.NewPos(x, 0) r.outPointLine.Position2 = fyne.NewPos(x, size.Height) r.objects = append(r.objects, r.outPointLine) } // Current position scrubber (white vertical line with circle on top) if t.duration > 0 { ratio := float32(t.position / t.duration) x := ratio * size.Width // Scrubber line if r.scrubberLine == nil { r.scrubberLine = canvas.NewLine(color.NRGBA{R: 255, G: 255, B: 255, A: 255}) } r.scrubberLine.StrokeWidth = 2 r.scrubberLine.Position1 = fyne.NewPos(x, 8) r.scrubberLine.Position2 = fyne.NewPos(x, size.Height) r.objects = append(r.objects, r.scrubberLine) // Scrubber circle (handle at top) if r.scrubberCircle == nil { r.scrubberCircle = canvas.NewCircle(color.NRGBA{R: 255, G: 255, B: 255, A: 255}) } circleRadius := float32(6) r.scrubberCircle.Resize(fyne.NewSize(circleRadius*2, circleRadius*2)) r.scrubberCircle.Move(fyne.NewPos(x-circleRadius, -circleRadius)) r.objects = append(r.objects, r.scrubberCircle) } } // Objects returns all canvas objects for the timeline func (r *timelineRenderer) Objects() []fyne.CanvasObject { return r.objects } // Destroy cleans up the renderer func (r *timelineRenderer) Destroy() {}