diff --git a/Splitter-UI/Controls/PreviewSlider.cs b/Splitter-UI/Controls/PreviewSlider.cs new file mode 100644 index 0000000..f9a4604 --- /dev/null +++ b/Splitter-UI/Controls/PreviewSlider.cs @@ -0,0 +1,299 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Point = Avalonia.Point; + +namespace Splitter_UI.Controls +{ + public sealed class PreviewSlider : Control + { + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum), 0d); + + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), 100d); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register( + nameof(Value), 0d, + coerce: (o, v) => + { + var slider = (PreviewSlider)o; + if (v < slider.Minimum) return slider.Minimum; + if (v > slider.Maximum) return slider.Maximum; + return v; + }); + + public static readonly StyledProperty SegmentDurationProperty = + AvaloniaProperty.Register(nameof(SegmentDuration), 1d); + + public static readonly StyledProperty TrackThicknessProperty = + AvaloniaProperty.Register(nameof(TrackThickness), 4d); + + public static readonly StyledProperty ThumbRadiusProperty = + AvaloniaProperty.Register(nameof(ThumbRadius), 8d); + + public static readonly StyledProperty TrackBrushProperty = + AvaloniaProperty.Register(nameof(TrackBrush), Brushes.Gray); + + public static readonly StyledProperty TrackFillBrushProperty = + AvaloniaProperty.Register(nameof(TrackFillBrush), Brushes.DodgerBlue); + + public static readonly StyledProperty ThumbBrushProperty = + AvaloniaProperty.Register(nameof(ThumbBrush), Brushes.White); + + public static readonly StyledProperty ThumbBorderBrushProperty = + AvaloniaProperty.Register(nameof(ThumbBorderBrush), Brushes.DodgerBlue); + + public static readonly StyledProperty ThumbBorderThicknessProperty = + AvaloniaProperty.Register(nameof(ThumbBorderThickness), 1d); + + public static readonly StyledProperty SegmentLineBrushProperty = + AvaloniaProperty.Register(nameof(SegmentLineBrush), Brushes.LightSalmon); + + public static readonly StyledProperty SegmentLineThicknessProperty = + AvaloniaProperty.Register(nameof(SegmentLineThickness), 1d); + + private bool _isDragging; + + public double Minimum + { + get => GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + public double Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public double Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public double SegmentDuration + { + get => GetValue(SegmentDurationProperty); + set => SetValue(SegmentDurationProperty, value); + } + + public double TrackThickness + { + get => GetValue(TrackThicknessProperty); + set => SetValue(TrackThicknessProperty, value); + } + + public double ThumbRadius + { + get => GetValue(ThumbRadiusProperty); + set => SetValue(ThumbRadiusProperty, value); + } + + public IBrush TrackBrush + { + get => GetValue(TrackBrushProperty); + set => SetValue(TrackBrushProperty, value); + } + + public IBrush TrackFillBrush + { + get => GetValue(TrackFillBrushProperty); + set => SetValue(TrackFillBrushProperty, value); + } + + public IBrush ThumbBrush + { + get => GetValue(ThumbBrushProperty); + set => SetValue(ThumbBrushProperty, value); + } + + public IBrush ThumbBorderBrush + { + get => GetValue(ThumbBorderBrushProperty); + set => SetValue(ThumbBorderBrushProperty, value); + } + + public double ThumbBorderThickness + { + get => GetValue(ThumbBorderThicknessProperty); + set => SetValue(ThumbBorderThicknessProperty, value); + } + + public IBrush SegmentLineBrush + { + get => GetValue(SegmentLineBrushProperty); + set => SetValue(SegmentLineBrushProperty, value); + } + + public double SegmentLineThickness + { + get => GetValue(SegmentLineThicknessProperty); + set => SetValue(SegmentLineThicknessProperty, value); + } + + static PreviewSlider() + { + FocusableProperty.OverrideDefaultValue(true); + + ValueProperty.Changed.AddClassHandler((s, _) => s.InvalidateVisual()); + MinimumProperty.Changed.AddClassHandler((s, _) => s.InvalidateVisual()); + MaximumProperty.Changed.AddClassHandler((s, _) => s.InvalidateVisual()); + SegmentDurationProperty.Changed.AddClassHandler((s, _) => s.InvalidateVisual()); + } + + + public PreviewSlider() + { + ClipToBounds = true; + + AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel); + AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel); + AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel); + AddHandler(PointerCaptureLostEvent, OnPointerCaptureLost, RoutingStrategies.Tunnel); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bounds = Bounds; + if (bounds.Width <= 0 || bounds.Height <= 0) + return; + + var centerY = bounds.Height / 2.0; + var left = ThumbRadius; + var right = bounds.Width - ThumbRadius; + + var trackThickness = TrackThickness; + var trackRect = new Rect(left, centerY - trackThickness / 2.0, right - left, trackThickness); + + context.FillRectangle(TrackBrush, trackRect); + + var range = Maximum - Minimum; + if (SegmentDuration > 0 && range > 0 && SegmentLineBrush != null && SegmentLineThickness > 0) + { + var pen = new Pen(SegmentLineBrush, SegmentLineThickness); + var totalSegments = (int)Math.Floor(range / SegmentDuration); + + for (var i = 1; i <= totalSegments; i++) + { + var segmentValue = Minimum + i * SegmentDuration; + var tSeg = (segmentValue - Minimum) / range; + var xSeg = left + tSeg * (right - left); + + var p1 = new Point(xSeg, centerY - trackThickness); + var p2 = new Point(xSeg, centerY + trackThickness); + context.DrawLine(pen, p1, p2); + } + } + + var t = (range <= 0) ? 0.0 : (Value - Minimum) / range; + t = Math.Clamp(t, 0.0, 1.0); + + var thumbX = left + t * (right - left); + + var fillRect = new Rect(left, centerY - trackThickness / 2.0, thumbX - left, trackThickness); + context.FillRectangle(TrackFillBrush, fillRect); + + var thumbRadius = ThumbRadius; + var thumbCenter = new Point(thumbX, centerY); + + var ellipse = new EllipseGeometry(new Rect( + thumbCenter.X - thumbRadius, + thumbCenter.Y - thumbRadius, + thumbRadius * 2, + thumbRadius * 2)); + + context.DrawGeometry(ThumbBrush, null, ellipse); + + if (ThumbBorderThickness > 0 && ThumbBorderBrush != null) + { + var pen = new Pen(ThumbBorderBrush, ThumbBorderThickness); + context.DrawGeometry(null, pen, ellipse); + } + + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + + var delta = e.Delta.Y; + if (delta == 0) + return; + + var step = (Maximum - Minimum) / 100.0; + if (step <= 0) + step = 1.0; + + if (delta > 0) + Value = Math.Clamp(Value - step, Minimum, Maximum); + else + Value = Math.Clamp(Value + step, Minimum, Maximum); + + e.Handled = true; + } + + private void OnPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!IsEnabled) + return; + + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + + e.Pointer.Capture(this); + UpdateValueFromPoint(e.GetPosition(this)); + _isDragging = true; + e.Handled = true; + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + if (!_isDragging) + return; + + UpdateValueFromPoint(e.GetPosition(this)); + e.Handled = true; + } + + private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + if (!_isDragging) + return; + + _isDragging = false; + e.Pointer.Capture(null); + e.Handled = true; + } + + private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) + { + _isDragging = false; + } + + private void UpdateValueFromPoint(Point point) + { + var bounds = Bounds; + var left = ThumbRadius; + var right = bounds.Width - ThumbRadius; + + if (right <= left) + return; + + var x = Math.Clamp(point.X, left, right); + var t = (x - left) / (right - left); + + var newValue = Minimum + t * (Maximum - Minimum); + Value = newValue; + + InvalidateVisual(); + } + } +} diff --git a/Splitter-UI/ViewModels/JobViewModel.cs b/Splitter-UI/ViewModels/JobViewModel.cs index 7ea0737..2fb91d7 100644 --- a/Splitter-UI/ViewModels/JobViewModel.cs +++ b/Splitter-UI/ViewModels/JobViewModel.cs @@ -22,6 +22,34 @@ public partial class JobViewModel : ObservableObject public string InputFile => Job.InputFile; public double DurationSeconds => Probe?.Duration ?? 0; + public double SegmentDuration + { + get + { + if (Probe == null || Probe.Duration <= 0) + return 58.0; + + var target = Job.OverrideTargetDuration ?? 58.0; + + int segments; + double segmentLength; + + if (Job.ForceFixed) + { + // Fixed chunk size, last one may be shorter + segments = (int)Math.Ceiling(Probe.Duration / target); + segmentLength = target; + } + else + { + // Equalized segments + segments = (int)Math.Ceiling(Probe.Duration / target); + segmentLength = Probe.Duration / segments; + } + + return segmentLength; + } + } public IRelayCommand StepForwardCommand { get; } public IRelayCommand StepBackwardCommand { get; } @@ -354,6 +382,7 @@ public partial class JobViewModel : ObservableObject crop = ClampCrop(r, w, h); Preview = new PreviewData(frame, detections ?? [], crop, Job.GravitateTo, pos, Job.Rotate); + OnPropertyChanged(nameof(SegmentDuration)); } catch (Exception ex) { @@ -415,9 +444,10 @@ public partial class JobViewModel : ObservableObject if (DurationSeconds <= 0) return; - var step = DurationSeconds * 0.1; // 10% of total duration + var duration = SegmentDuration; + var segment = Math.Round(SliderLiveValue / duration, MidpointRounding.ToZero)+1; - SliderLiveValue = Math.Min(DurationSeconds, SliderLiveValue + step); + SliderLiveValue = Math.Min(DurationSeconds - duration, segment * duration); // trigger seek in your playback pipeline here } @@ -426,9 +456,10 @@ public partial class JobViewModel : ObservableObject if (DurationSeconds <= 0) return; - var step = DurationSeconds * 0.1; // 10% of total duration + var duration = SegmentDuration; + var segment = Math.Max(0, Math.Round(SliderLiveValue / duration, MidpointRounding.ToZero)-1); - SliderLiveValue = Math.Max(0, SliderLiveValue - step); + SliderLiveValue = segment * duration; // trigger seek in your playback pipeline here } diff --git a/Splitter-UI/Views/PreviewPane.axaml b/Splitter-UI/Views/PreviewPane.axaml index aa776f0..a32461d 100644 --- a/Splitter-UI/Views/PreviewPane.axaml +++ b/Splitter-UI/Views/PreviewPane.axaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="clr-namespace:Splitter_UI.ViewModels" xmlns:local="clr-namespace:Splitter_UI.Views" + xmlns:controls="clr-namespace:Splitter_UI.Controls" x:Class="Splitter_UI.Views.PreviewPane" x:DataType="vm:PreviewPaneViewModel"> @@ -35,12 +36,19 @@ VerticalAlignment="Center" HorizontalAlignment="Center" /> - + +