mirror of
https://github.com/unclshura/splitter.git
synced 2026-06-22 00:22:01 +00:00
Compare commits
No commits in common. "2058ae0f7e79cc7096d15d25cdf60460b593d23e" and "ddafb40ca76f8d7443d9f3e6277a16a8afa22f43" have entirely different histories.
2058ae0f7e
...
ddafb40ca7
@ -1,957 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Globalization;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Point = Avalonia.Point;
|
|
||||||
|
|
||||||
namespace Splitter_UI.Controls;
|
|
||||||
|
|
||||||
public class TimelinePreviewSlider : Control, IDisposable
|
|
||||||
{
|
|
||||||
// Public properties
|
|
||||||
public static readonly StyledProperty<JobViewModel?> ViewModelProperty =
|
|
||||||
AvaloniaProperty.Register<TimelinePreviewSlider, JobViewModel?>(nameof(ViewModel));
|
|
||||||
|
|
||||||
public JobViewModel? ViewModel
|
|
||||||
{
|
|
||||||
get => GetValue(ViewModelProperty);
|
|
||||||
set => SetValue(ViewModelProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double PixelsPerSecond
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.DurationSeconds <= 0 || Bounds.Width <= 0)
|
|
||||||
return 10000; // fallback value
|
|
||||||
|
|
||||||
// Full control width maps to full video duration
|
|
||||||
return Bounds.Width / vm.DurationSeconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush?> SegmentFillProperty =
|
|
||||||
AvaloniaProperty.Register<TimelinePreviewSlider, IBrush?>(nameof(SegmentFill), Brushes.DimGray);
|
|
||||||
|
|
||||||
public IBrush? SegmentFill
|
|
||||||
{
|
|
||||||
get => GetValue(SegmentFillProperty);
|
|
||||||
set => SetValue(SegmentFillProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly StyledProperty<IBrush?> MarkerStrokeProperty =
|
|
||||||
AvaloniaProperty.Register<TimelinePreviewSlider, IBrush?>(nameof(MarkerStroke), Brushes.White);
|
|
||||||
|
|
||||||
public IBrush? MarkerStroke
|
|
||||||
{
|
|
||||||
get => GetValue(MarkerStrokeProperty);
|
|
||||||
set => SetValue(MarkerStrokeProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visual constants
|
|
||||||
private const double _timelineHeight = 80;
|
|
||||||
private const double _markerLineHeight = 36;
|
|
||||||
private const double _markerLineWidth = 2;
|
|
||||||
private const double _markerTriangleSize = 8;
|
|
||||||
private const double _segmentBarHeight = 40;
|
|
||||||
private const int _maxPreviewCacheItems = 128;
|
|
||||||
|
|
||||||
// Internal state
|
|
||||||
private readonly LruCache<string, Bitmap> _previewCache = new(_maxPreviewCacheItems);
|
|
||||||
private readonly Dictionary<string, CancellationTokenSource> _previewLoadCts = new();
|
|
||||||
private readonly object _cacheLock = new();
|
|
||||||
|
|
||||||
private IDisposable? _segmentsSubscription;
|
|
||||||
private bool _isInternalSliderUpdate;
|
|
||||||
private JobViewModel? _currentVm;
|
|
||||||
|
|
||||||
// Interaction state
|
|
||||||
private bool _isPointerCaptured;
|
|
||||||
private Point _lastPointerPoint;
|
|
||||||
private DragMode _dragMode = DragMode.None;
|
|
||||||
private int _activeSegmentIndex = -1;
|
|
||||||
private bool _isSplitModifierActive;
|
|
||||||
|
|
||||||
// Throttle invalidation during drag
|
|
||||||
private DateTime _lastInvalidate = DateTime.MinValue;
|
|
||||||
private readonly TimeSpan _invalidateThrottle = TimeSpan.FromMilliseconds(16); // ~60Hz
|
|
||||||
|
|
||||||
public TimelinePreviewSlider()
|
|
||||||
{
|
|
||||||
Focusable = true;
|
|
||||||
Height = _timelineHeight;
|
|
||||||
ClipToBounds = true;
|
|
||||||
|
|
||||||
// Use property change override instead of GetObservable.Subscribe to avoid IObserver compile issues.
|
|
||||||
PointerPressed += OnPointerPressed;
|
|
||||||
PointerMoved += OnPointerMoved;
|
|
||||||
PointerReleased += OnPointerReleased;
|
|
||||||
PointerCaptureLost += OnPointerCaptureLost;
|
|
||||||
KeyDown += OnKeyDown;
|
|
||||||
KeyUp += OnKeyUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override to detect ViewModel property changes
|
|
||||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
||||||
{
|
|
||||||
base.OnPropertyChanged(change);
|
|
||||||
|
|
||||||
if (change.Property == ViewModelProperty)
|
|
||||||
{
|
|
||||||
OnViewModelChanged((JobViewModel?)change.NewValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnViewModelChanged(JobViewModel? vm)
|
|
||||||
{
|
|
||||||
UnsubscribeFromViewModel();
|
|
||||||
_previewCache.Clear();
|
|
||||||
CancelAllPreviewLoads();
|
|
||||||
|
|
||||||
if (vm != null)
|
|
||||||
{
|
|
||||||
_segmentsSubscription = SubscribeToSegments(vm.Segments);
|
|
||||||
vm.PropertyChanged += OnVmPropertyChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnVmPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.PropertyName == nameof(JobViewModel.SliderLiveValue))
|
|
||||||
{
|
|
||||||
if (_isInternalSliderUpdate)
|
|
||||||
return;
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IDisposable SubscribeToSegments(ObservableCollection<Segment> segments)
|
|
||||||
{
|
|
||||||
NotifyCollectionChangedEventHandler handler = (s, e) =>
|
|
||||||
{
|
|
||||||
Dispatcher.UIThread.Post(() => {
|
|
||||||
InvalidateVisual();
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
};
|
|
||||||
segments.CollectionChanged += handler;
|
|
||||||
return Disposable.Create(() => segments.CollectionChanged -= handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UnsubscribeFromViewModel()
|
|
||||||
{
|
|
||||||
if (_currentVm != null)
|
|
||||||
_currentVm.PropertyChanged -= OnVmPropertyChanged;
|
|
||||||
|
|
||||||
_currentVm = null;
|
|
||||||
|
|
||||||
_segmentsSubscription?.Dispose();
|
|
||||||
_segmentsSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelAllPreviewLoads()
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
foreach (var cts in _previewLoadCts.Values)
|
|
||||||
{
|
|
||||||
try { cts.Cancel(); } catch { }
|
|
||||||
}
|
|
||||||
_previewLoadCts.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Render(DrawingContext dc)
|
|
||||||
{
|
|
||||||
base.Render(dc);
|
|
||||||
|
|
||||||
var vm = ViewModel;
|
|
||||||
var bounds = new Rect(0, 0, Bounds.Width, Bounds.Height);
|
|
||||||
|
|
||||||
// Background
|
|
||||||
dc.FillRectangle(Brushes.Black, bounds);
|
|
||||||
|
|
||||||
DrawContinuousPreviewStrip(dc);
|
|
||||||
DrawGapOverlays(dc);
|
|
||||||
DrawOverlongSegmentOverlays(dc);
|
|
||||||
|
|
||||||
if (vm == null || vm.DurationSeconds <= 0 || vm.Segments.Count == 0)
|
|
||||||
{
|
|
||||||
// draw empty ruler
|
|
||||||
DrawRuler(dc, 0, vm?.DurationSeconds ?? 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw segments and previews
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
var segRect = SegmentRectFor(seg);
|
|
||||||
if (segRect.Width <= 0) continue;
|
|
||||||
|
|
||||||
// Segment background
|
|
||||||
var segBrush = new Pen(SegmentFill ?? Brushes.DimGray, 1);
|
|
||||||
var segRoundedRect = new Rect(segRect.X, segRect.Y + (Bounds.Height - _segmentBarHeight) / 2, segRect.Width, _segmentBarHeight);
|
|
||||||
var geom = new StreamGeometry();
|
|
||||||
using (var ctx = geom.Open())
|
|
||||||
{
|
|
||||||
ctx.BeginFigure(new Point(segRoundedRect.X, segRoundedRect.Y), true);
|
|
||||||
ctx.LineTo( new Point(segRoundedRect.X + segRoundedRect.Width, segRoundedRect.Y));
|
|
||||||
ctx.LineTo( new Point(segRoundedRect.X + segRoundedRect.Width, segRoundedRect.Y + segRoundedRect.Height));
|
|
||||||
ctx.LineTo( new Point(segRoundedRect.X, segRoundedRect.Y + segRoundedRect.Height));
|
|
||||||
ctx.EndFigure(true);
|
|
||||||
}
|
|
||||||
dc.DrawGeometry(null, segBrush, geom);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw markers on top
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
DrawMarker(dc, seg.Start, true);
|
|
||||||
DrawMarker(dc, seg.End, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw current position indicator
|
|
||||||
DrawPositionIndicator(dc, vm.SliderLiveValue);
|
|
||||||
|
|
||||||
// Draw ruler
|
|
||||||
DrawRuler(dc, 0, vm.DurationSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawRuler(DrawingContext dc, double startSec, double endSec)
|
|
||||||
{
|
|
||||||
var height = Bounds.Height;
|
|
||||||
var y = height - 18;
|
|
||||||
var pen = new Pen(Brushes.Gray, 1);
|
|
||||||
dc.DrawLine(pen, new Point(0, y), new Point(Bounds.Width, y));
|
|
||||||
|
|
||||||
if (ViewModel == null || ViewModel.DurationSeconds <= 0) return;
|
|
||||||
|
|
||||||
var totalSec = ViewModel.DurationSeconds;
|
|
||||||
var approxTicks = Math.Max(2, (int)(Bounds.Width / 100));
|
|
||||||
var tickSec = Math.Max(1.0, totalSec / approxTicks);
|
|
||||||
|
|
||||||
for (double t = 0; t <= totalSec; t += tickSec)
|
|
||||||
{
|
|
||||||
var x = SecondsToPixel(t);
|
|
||||||
dc.DrawLine(pen, new Point(x, y), new Point(x, y - 6));
|
|
||||||
var text = FormatTime(t);
|
|
||||||
var textBrush = Brushes.LightGray; // or new SolidColorBrush(Color.Parse("#FFCCCCCC"));
|
|
||||||
var ft = new FormattedText(
|
|
||||||
text,
|
|
||||||
CultureInfo.CurrentUICulture,
|
|
||||||
FlowDirection.LeftToRight,
|
|
||||||
Typeface.Default,
|
|
||||||
12,
|
|
||||||
textBrush);
|
|
||||||
|
|
||||||
dc.DrawText(ft, new Point(x + 2, y - 18));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawContinuousPreviewStrip(DrawingContext dc)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.DurationSeconds <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
|
||||||
double stripHeight = _segmentBarHeight;
|
|
||||||
|
|
||||||
double currentX = 0;
|
|
||||||
double endX = Bounds.Width;
|
|
||||||
|
|
||||||
var bmp = GetPreview(0);
|
|
||||||
var noPreviewAvailable = bmp == null;
|
|
||||||
if (bmp == null)
|
|
||||||
{
|
|
||||||
StartPreviewLoad(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var previewScale = (double)stripHeight / bmp.PixelSize.Height;
|
|
||||||
var previewTileWidth = bmp.PixelSize.Width * previewScale;
|
|
||||||
|
|
||||||
using (dc.PushClip(new Rect(0, stripY, Bounds.Width, stripHeight)))
|
|
||||||
{
|
|
||||||
while (currentX < endX)
|
|
||||||
{
|
|
||||||
double posSec = PixelToSeconds(currentX);
|
|
||||||
if (posSec < 0) posSec = 0;
|
|
||||||
if (posSec > vm.DurationSeconds) posSec = vm.DurationSeconds;
|
|
||||||
|
|
||||||
bmp = GetPreview(posSec);
|
|
||||||
|
|
||||||
if (bmp == null)
|
|
||||||
{
|
|
||||||
StartPreviewLoad(posSec);
|
|
||||||
|
|
||||||
// advance by estimated width
|
|
||||||
currentX += previewTileWidth;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// scale full frame to strip height
|
|
||||||
double scale = stripHeight / bmp.PixelSize.Height;
|
|
||||||
double tileWidth = bmp.PixelSize.Width * scale;
|
|
||||||
|
|
||||||
var src = new Rect(0, 0, bmp.PixelSize.Width, bmp.PixelSize.Height);
|
|
||||||
var dst = new Rect(currentX, stripY, tileWidth, stripHeight);
|
|
||||||
|
|
||||||
dc.DrawImage(bmp, src, dst);
|
|
||||||
|
|
||||||
currentX += tileWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawGapOverlays(DrawingContext dc)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.Segments.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
|
||||||
double stripHeight = _segmentBarHeight;
|
|
||||||
|
|
||||||
var gapBrush = new SolidColorBrush(Color.FromArgb(190, 80, 80, 80));
|
|
||||||
|
|
||||||
double lastEnd = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
|
|
||||||
if (seg.Start > lastEnd)
|
|
||||||
{
|
|
||||||
double gapLeft = SecondsToPixel(lastEnd);
|
|
||||||
double gapRight = SecondsToPixel(seg.Start);
|
|
||||||
double w = gapRight - gapLeft;
|
|
||||||
|
|
||||||
if (w > 0)
|
|
||||||
dc.FillRectangle(gapBrush, new Rect(gapLeft, stripY, w, stripHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
lastEnd = seg.End;
|
|
||||||
}
|
|
||||||
|
|
||||||
// tail gap
|
|
||||||
if (lastEnd < vm.DurationSeconds)
|
|
||||||
{
|
|
||||||
double gapLeft = SecondsToPixel(lastEnd);
|
|
||||||
double gapRight = SecondsToPixel(vm.DurationSeconds);
|
|
||||||
double w = gapRight - gapLeft;
|
|
||||||
|
|
||||||
if (w > 0)
|
|
||||||
dc.FillRectangle(gapBrush, new Rect(gapLeft, stripY, w, stripHeight));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawOverlongSegmentOverlays(DrawingContext dc)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null || vm.Segments.Count == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (vm.OverrideTargetDuration <= 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double stripY = (Bounds.Height - _segmentBarHeight) / 2;
|
|
||||||
double stripHeight = _segmentBarHeight;
|
|
||||||
|
|
||||||
var overBrush = new SolidColorBrush(Color.FromArgb(128, 255, 0, 0)); // 50% red
|
|
||||||
|
|
||||||
foreach (var seg in vm.Segments)
|
|
||||||
{
|
|
||||||
double length = seg.End - seg.Start;
|
|
||||||
if (length <= vm.OverrideTargetDuration)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
double left = SecondsToPixel(seg.Start);
|
|
||||||
double right = SecondsToPixel(seg.End);
|
|
||||||
double w = right - left;
|
|
||||||
|
|
||||||
if (w > 0)
|
|
||||||
dc.FillRectangle(overBrush, new Rect(left, stripY, w, stripHeight));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Bitmap? GetPreview(double pos)
|
|
||||||
{
|
|
||||||
var key = PreviewCacheKey(pos);
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
_previewCache.TryGet(key, out var bmp);
|
|
||||||
return bmp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartPreviewLoad(double pos)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var key = PreviewCacheKey(pos);
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
if (_previewLoadCts.ContainsKey(key) || _previewCache.ContainsKey(key))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
|
||||||
_previewLoadCts[key] = cts;
|
|
||||||
|
|
||||||
// Run an async loader on threadpool
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// call host-provided async GetPreview
|
|
||||||
var bmp = await vm.GetThumbnail(pos).ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (bmp != null && !cts.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
_previewCache.Add(key, bmp);
|
|
||||||
_previewLoadCts.Remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// notify UI thread to redraw
|
|
||||||
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lock (_cacheLock)
|
|
||||||
{
|
|
||||||
_previewLoadCts.Remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
lock (_cacheLock) { _previewLoadCts.Remove(key); }
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
lock (_cacheLock) { _previewLoadCts.Remove(key); }
|
|
||||||
}
|
|
||||||
}, cts.Token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private string PreviewCacheKey(double pos) => $"{pos:F3}";
|
|
||||||
|
|
||||||
private Rect SegmentRectFor(Segment seg)
|
|
||||||
{
|
|
||||||
var left = SecondsToPixel(seg.Start);
|
|
||||||
var right = SecondsToPixel(seg.End);
|
|
||||||
var y = 0;
|
|
||||||
return new Rect(left, y, Math.Max(0, right - left), Bounds.Height);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double SecondsToPixel(double seconds)
|
|
||||||
{
|
|
||||||
return seconds * PixelsPerSecond;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double PixelToSeconds(double px)
|
|
||||||
{
|
|
||||||
return px / PixelsPerSecond;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawMarker(DrawingContext dc, double seconds, bool isStart)
|
|
||||||
{
|
|
||||||
var x = SecondsToPixel(seconds);
|
|
||||||
var top = (Bounds.Height - _segmentBarHeight) / 2 - _markerTriangleSize - 2;
|
|
||||||
var lineTop = top + _markerTriangleSize + 2;
|
|
||||||
var lineBottom = lineTop + _markerLineHeight;
|
|
||||||
|
|
||||||
double midY = top + _markerTriangleSize / 2.0;
|
|
||||||
|
|
||||||
var tri = new StreamGeometry();
|
|
||||||
using (var ctx = tri.Open())
|
|
||||||
{
|
|
||||||
if (isStart)
|
|
||||||
{
|
|
||||||
// segment is to the right -> triangle points right
|
|
||||||
var vTop = new Point(x, top);
|
|
||||||
var vBottom = new Point(x, top + _markerTriangleSize);
|
|
||||||
var point = new Point(x + _markerTriangleSize, midY);
|
|
||||||
|
|
||||||
ctx.BeginFigure(point, true);
|
|
||||||
ctx.LineTo(vBottom);
|
|
||||||
ctx.LineTo(vTop);
|
|
||||||
ctx.EndFigure(true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// segment is to the left -> triangle points left
|
|
||||||
var vTop = new Point(x, top);
|
|
||||||
var vBottom = new Point(x, top + _markerTriangleSize);
|
|
||||||
var point = new Point(x - _markerTriangleSize, midY);
|
|
||||||
|
|
||||||
ctx.BeginFigure(point, true);
|
|
||||||
ctx.LineTo(vTop);
|
|
||||||
ctx.LineTo(vBottom);
|
|
||||||
ctx.EndFigure(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dc.DrawGeometry(MarkerStroke ?? Brushes.White, null, tri);
|
|
||||||
|
|
||||||
var pen = new Pen(MarkerStroke ?? Brushes.White, _markerLineWidth);
|
|
||||||
dc.DrawLine(pen, new Point(x, lineTop), new Point(x, lineBottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawPositionIndicator(DrawingContext dc, double seconds)
|
|
||||||
{
|
|
||||||
var x = SecondsToPixel(seconds);
|
|
||||||
var pen = new Pen(Brushes.Red, 1.5);
|
|
||||||
dc.DrawLine(pen, new Point(x, 0), new Point(x, Bounds.Height));
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatTime(double seconds)
|
|
||||||
{
|
|
||||||
var ts = TimeSpan.FromSeconds(Math.Max(0, seconds));
|
|
||||||
if (ts.TotalHours >= 1)
|
|
||||||
return $"{(int)ts.TotalHours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}";
|
|
||||||
return $"{ts.Minutes:D2}:{ts.Seconds:D2}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interaction
|
|
||||||
|
|
||||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
Focus();
|
|
||||||
|
|
||||||
var p = e.GetPosition(this);
|
|
||||||
_lastPointerPoint = p;
|
|
||||||
_isSplitModifierActive = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
|
||||||
|
|
||||||
var hit = HitTestAtPoint(p);
|
|
||||||
if (hit.Type == HitType.StartMarker)
|
|
||||||
{
|
|
||||||
BeginDrag(DragMode.DragStartMarker, hit.SegmentIndex, e);
|
|
||||||
}
|
|
||||||
else if (hit.Type == HitType.EndMarker)
|
|
||||||
{
|
|
||||||
BeginDrag(DragMode.DragEndMarker, hit.SegmentIndex, e);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// any other hit just moves playhead
|
|
||||||
SetPlayheadFromPoint(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isSplitModifierActive && hit.Type == HitType.SegmentBody)
|
|
||||||
{
|
|
||||||
var sec = PixelToSeconds(p.X);
|
|
||||||
TrySplitSegmentAt(hit.SegmentIndex, sec);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Pointer.Capture(this);
|
|
||||||
_isPointerCaptured = true;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
|
||||||
{
|
|
||||||
if (!_isPointerCaptured) return;
|
|
||||||
var p = e.GetPosition(this);
|
|
||||||
|
|
||||||
if (_dragMode == DragMode.None)
|
|
||||||
{
|
|
||||||
_lastPointerPoint = p;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
|
|
||||||
var sec = PixelToSeconds(p.X);
|
|
||||||
sec = Math.Max(0, Math.Min(vm.DurationSeconds, sec));
|
|
||||||
|
|
||||||
switch (_dragMode)
|
|
||||||
{
|
|
||||||
case DragMode.DragStartMarker:
|
|
||||||
MoveSegmentStart(_activeSegmentIndex, sec);
|
|
||||||
break;
|
|
||||||
case DragMode.DragEndMarker:
|
|
||||||
MoveSegmentEnd(_activeSegmentIndex, sec);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
_isInternalSliderUpdate = true;
|
|
||||||
vm.SliderLiveValue = sec;
|
|
||||||
_isInternalSliderUpdate = false;
|
|
||||||
|
|
||||||
ThrottledInvalidate();
|
|
||||||
_lastPointerPoint = p;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_isPointerCaptured)
|
|
||||||
{
|
|
||||||
e.Pointer.Capture(null);
|
|
||||||
_isPointerCaptured = false;
|
|
||||||
}
|
|
||||||
_dragMode = DragMode.None;
|
|
||||||
_activeSegmentIndex = -1;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
|
|
||||||
{
|
|
||||||
_isPointerCaptured = false;
|
|
||||||
_dragMode = DragMode.None;
|
|
||||||
_activeSegmentIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl)
|
|
||||||
{
|
|
||||||
_isSplitModifierActive = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.Key == Key.Delete)
|
|
||||||
{
|
|
||||||
TryDeleteCurrentSegment();
|
|
||||||
e.Handled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void OnKeyUp(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl)
|
|
||||||
_isSplitModifierActive = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TryDeleteCurrentSegment()
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double pos = vm.SliderLiveValue;
|
|
||||||
|
|
||||||
int idx = -1;
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var s = vm.Segments[i];
|
|
||||||
if (pos >= s.Start && pos <= s.End)
|
|
||||||
{
|
|
||||||
idx = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (idx == -1)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
if (idx >= 0 && idx < vm.Segments.Count)
|
|
||||||
vm.Segments.RemoveAt(idx);
|
|
||||||
|
|
||||||
if (vm.Segments.Count == 0)
|
|
||||||
vm.GenerateSegments();
|
|
||||||
|
|
||||||
}, DispatcherPriority.Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void BeginDrag(DragMode mode, int segmentIndex, PointerPressedEventArgs e)
|
|
||||||
{
|
|
||||||
_dragMode = mode;
|
|
||||||
_activeSegmentIndex = segmentIndex;
|
|
||||||
_lastPointerPoint = e.GetPosition(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ThrottledInvalidate()
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
if (now - _lastInvalidate > _invalidateThrottle)
|
|
||||||
{
|
|
||||||
_lastInvalidate = now;
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetPlayheadFromPoint(Point p)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
double sec = PixelToSeconds(p.X);
|
|
||||||
sec = Math.Max(0, Math.Min(vm.DurationSeconds, sec));
|
|
||||||
|
|
||||||
_isInternalSliderUpdate = true;
|
|
||||||
vm.SliderLiveValue = sec;
|
|
||||||
_isInternalSliderUpdate = false;
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MoveSegmentStart(int index, double newStart)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
var min = index == 0 ? 0.0 : vm.Segments[index - 1].End;
|
|
||||||
var max = seg.End - 0.001;
|
|
||||||
var clamped = Math.Max(min, Math.Min(max, newStart));
|
|
||||||
if (Math.Abs(clamped - seg.Start) < 1e-6) return;
|
|
||||||
|
|
||||||
var newSeg = seg with { Start = clamped };
|
|
||||||
vm.Segments[index] = newSeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MoveSegmentEnd(int index, double newEnd)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
var min = seg.Start + 0.001;
|
|
||||||
var max = index == vm.Segments.Count - 1 ? vm.DurationSeconds : vm.Segments[index + 1].Start;
|
|
||||||
var clamped = Math.Max(min, Math.Min(max, newEnd));
|
|
||||||
if (Math.Abs(clamped - seg.End) < 1e-6) return;
|
|
||||||
|
|
||||||
var newSeg = seg with { End = clamped };
|
|
||||||
vm.Segments[index] = newSeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void MoveSegmentByDelta(int index, double deltaSec)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
var leftLimit = index == 0 ? 0.0 : vm.Segments[index - 1].End;
|
|
||||||
var rightLimit = index == vm.Segments.Count - 1 ? vm.DurationSeconds : vm.Segments[index + 1].Start;
|
|
||||||
|
|
||||||
var newStart = seg.Start + deltaSec;
|
|
||||||
var newEnd = seg.End + deltaSec;
|
|
||||||
|
|
||||||
// clamp so segment stays within neighbors
|
|
||||||
var segLength = seg.End - seg.Start;
|
|
||||||
if (newStart < leftLimit)
|
|
||||||
{
|
|
||||||
newStart = leftLimit;
|
|
||||||
newEnd = newStart + segLength;
|
|
||||||
}
|
|
||||||
if (newEnd > rightLimit)
|
|
||||||
{
|
|
||||||
newEnd = rightLimit;
|
|
||||||
newStart = newEnd - segLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
// apply
|
|
||||||
vm.Segments[index] = seg with { Start = newStart, End = newEnd };
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TrySplitSegmentAt(int index, double sec)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null) return;
|
|
||||||
if (index < 0 || index >= vm.Segments.Count) return;
|
|
||||||
|
|
||||||
var seg = vm.Segments[index];
|
|
||||||
if (sec <= seg.Start + 0.001 || sec >= seg.End - 0.001) return;
|
|
||||||
|
|
||||||
var left = seg with { End = sec };
|
|
||||||
var right = seg with { Start = sec };
|
|
||||||
vm.Segments[index] = left;
|
|
||||||
vm.Segments.Insert(index + 1, right);
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
private HitResult HitTestAtPoint(Point p)
|
|
||||||
{
|
|
||||||
var vm = ViewModel;
|
|
||||||
if (vm == null)
|
|
||||||
return new HitResult(HitType.None, -1);
|
|
||||||
|
|
||||||
double topRegion = Bounds.Height / 4.0;
|
|
||||||
|
|
||||||
for (int i = 0; i < vm.Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var seg = vm.Segments[i];
|
|
||||||
double startX = SecondsToPixel(seg.Start);
|
|
||||||
double endX = SecondsToPixel(seg.End);
|
|
||||||
|
|
||||||
// marker hit only in top 1/4
|
|
||||||
if (p.Y >= 0 && p.Y <= topRegion)
|
|
||||||
{
|
|
||||||
// start marker triangle footprint: [startX .. startX + size]
|
|
||||||
if (p.X >= startX && p.X <= startX + _markerTriangleSize)
|
|
||||||
return new HitResult(HitType.StartMarker, i);
|
|
||||||
|
|
||||||
// end marker triangle footprint: [endX - size .. endX]
|
|
||||||
if (p.X >= endX - _markerTriangleSize && p.X <= endX)
|
|
||||||
return new HitResult(HitType.EndMarker, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// segment body (no drag, only click/split)
|
|
||||||
if (p.X >= startX && p.X <= endX && p.Y >= 0 && p.Y <= Bounds.Height)
|
|
||||||
return new HitResult(HitType.SegmentBody, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HitResult(HitType.Gap, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IDisposable
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
UnsubscribeFromViewModel();
|
|
||||||
CancelAllPreviewLoads();
|
|
||||||
_previewCache.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helpers and small types
|
|
||||||
|
|
||||||
private enum DragMode
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
DragStartMarker,
|
|
||||||
DragEndMarker
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum HitType
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
StartMarker,
|
|
||||||
EndMarker,
|
|
||||||
SegmentBody,
|
|
||||||
Gap
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly struct HitResult
|
|
||||||
{
|
|
||||||
public HitType Type { get; }
|
|
||||||
public int SegmentIndex { get; }
|
|
||||||
public HitResult(HitType type, int idx) { Type = type; SegmentIndex = idx; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple LRU cache for Bitmaps
|
|
||||||
private class LruCache<TKey, TValue> where TKey : notnull where TValue : class
|
|
||||||
{
|
|
||||||
private readonly int _capacity;
|
|
||||||
private readonly Dictionary<TKey, LinkedListNode<(TKey key, TValue value)>> _map;
|
|
||||||
private readonly LinkedList<(TKey key, TValue value)> _list;
|
|
||||||
private readonly object _sync = new();
|
|
||||||
|
|
||||||
public LruCache(int capacity)
|
|
||||||
{
|
|
||||||
_capacity = Math.Max(1, capacity);
|
|
||||||
_map = new Dictionary<TKey, LinkedListNode<(TKey, TValue)>>();
|
|
||||||
_list = new LinkedList<(TKey, TValue)>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool TryGet(TKey key, out TValue? value)
|
|
||||||
{
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
if (_map.TryGetValue(key, out var node))
|
|
||||||
{
|
|
||||||
value = node.Value.value;
|
|
||||||
_list.Remove(node);
|
|
||||||
_list.AddFirst(node);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
value = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Add(TKey key, TValue value)
|
|
||||||
{
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
if (_map.TryGetValue(key, out var node))
|
|
||||||
{
|
|
||||||
_list.Remove(node);
|
|
||||||
_map.Remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newNode = new LinkedListNode<(TKey, TValue)>((key, value));
|
|
||||||
_list.AddFirst(newNode);
|
|
||||||
_map[key] = newNode;
|
|
||||||
|
|
||||||
if (_map.Count > _capacity)
|
|
||||||
{
|
|
||||||
var last = _list.Last!;
|
|
||||||
_map.Remove(last.Value.key);
|
|
||||||
_list.RemoveLast();
|
|
||||||
if (last.Value.value is IDisposable d)
|
|
||||||
{
|
|
||||||
try { d.Dispose(); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool ContainsKey(TKey key)
|
|
||||||
{
|
|
||||||
lock (_sync) return _map.ContainsKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
|
||||||
{
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
foreach (var node in _list)
|
|
||||||
{
|
|
||||||
if (node.value is IDisposable d)
|
|
||||||
{
|
|
||||||
try { d.Dispose(); } catch { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_map.Clear();
|
|
||||||
_list.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disposable helper
|
|
||||||
private static class Disposable
|
|
||||||
{
|
|
||||||
public static IDisposable Create(Action dispose)
|
|
||||||
{
|
|
||||||
return new AnonymousDisposable(dispose);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class AnonymousDisposable : IDisposable
|
|
||||||
{
|
|
||||||
private Action? _dispose;
|
|
||||||
public AnonymousDisposable(Action dispose) { _dispose = dispose; }
|
|
||||||
public void Dispose() { var d = Interlocked.Exchange(ref _dispose, null); d?.Invoke(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,11 +7,11 @@ namespace Splitter_UI.Services;
|
|||||||
|
|
||||||
public sealed class ThumbnailService : IThumbnailService
|
public sealed class ThumbnailService : IThumbnailService
|
||||||
{
|
{
|
||||||
public const int ThumbWidth = 160;
|
private const int _thumbWidth = 160;
|
||||||
public const int ThumbHeight = 90;
|
private const int _thumbHeight = 90;
|
||||||
|
|
||||||
private readonly byte [] _bgrBuffer = new byte[ThumbWidth * ThumbHeight * 3];
|
private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3];
|
||||||
private readonly byte [] _bgraBuffer = new byte[ThumbWidth * ThumbHeight * 4];
|
private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4];
|
||||||
|
|
||||||
public SemaphoreSlim _lock = new(1,1);
|
public SemaphoreSlim _lock = new(1,1);
|
||||||
|
|
||||||
@ -49,15 +49,15 @@ public sealed class ThumbnailService : IThumbnailService
|
|||||||
int? height = null,
|
int? height = null,
|
||||||
int? rotateDegree = null)
|
int? rotateDegree = null)
|
||||||
{
|
{
|
||||||
width ??= ThumbWidth;
|
width ??= _thumbWidth;
|
||||||
height ??= ThumbHeight;
|
height ??= _thumbHeight;
|
||||||
skip ??= TimeSpan.Zero;
|
skip ??= TimeSpan.Zero;
|
||||||
|
|
||||||
// buffer for BGR24 → 3 bytes per pixel
|
// buffer for BGR24 → 3 bytes per pixel
|
||||||
|
|
||||||
var canUseStaticBuffers =
|
var canUseStaticBuffers =
|
||||||
width.Value == ThumbWidth &&
|
width.Value == _thumbWidth &&
|
||||||
height.Value == ThumbHeight;
|
height.Value == _thumbHeight;
|
||||||
|
|
||||||
var bgrBuffer = canUseStaticBuffers ? _bgrBuffer : new byte[width.Value * height.Value * 3];
|
var bgrBuffer = canUseStaticBuffers ? _bgrBuffer : new byte[width.Value * height.Value * 3];
|
||||||
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
|
var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4];
|
||||||
|
|||||||
@ -8,8 +8,6 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
|
|
||||||
namespace Splitter_UI.ViewModels;
|
namespace Splitter_UI.ViewModels;
|
||||||
|
|
||||||
public record Segment(double Start, double End);
|
|
||||||
|
|
||||||
public partial class JobViewModel : ObservableObject
|
public partial class JobViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private SingleJob Job { get; }
|
private SingleJob Job { get; }
|
||||||
@ -72,8 +70,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
public ObservableCollection<ParameterEntry> ParametersList { get; }
|
public ObservableCollection<ParameterEntry> ParametersList { get; }
|
||||||
= new();
|
= new();
|
||||||
|
|
||||||
public ObservableCollection<Segment> Segments { get; } = new();
|
|
||||||
|
|
||||||
public string CropText
|
public string CropText
|
||||||
{
|
{
|
||||||
get => Job.Crop is { } c ? $"{c.width},{c.height}" : "";
|
get => Job.Crop is { } c ? $"{c.width},{c.height}" : "";
|
||||||
@ -315,8 +311,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(Probe))
|
if (e.PropertyName == nameof(Probe))
|
||||||
{
|
{
|
||||||
if (Segments.Count == 0)
|
|
||||||
GenerateSegments();
|
|
||||||
OnPropertyChanged(nameof(DurationSeconds));
|
OnPropertyChanged(nameof(DurationSeconds));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -332,21 +326,6 @@ public partial class JobViewModel : ObservableObject
|
|||||||
_debounceTimer.Tick += DebounceTimerTick;
|
_debounceTimer.Tick += DebounceTimerTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void GenerateSegments()
|
|
||||||
{
|
|
||||||
Segments.Clear();
|
|
||||||
if (Probe == null || Probe.Duration <= 0)
|
|
||||||
return;
|
|
||||||
var duration = SegmentDuration;
|
|
||||||
var segments = (int)Math.Ceiling(Probe.Duration / duration);
|
|
||||||
for (int i = 0; i < segments; i++)
|
|
||||||
{
|
|
||||||
var start = i * duration;
|
|
||||||
var end = Math.Min(start + duration, Probe.Duration);
|
|
||||||
Segments.Add(new Segment(start, end));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void CopyFrom(JobViewModel src)
|
public void CopyFrom(JobViewModel src)
|
||||||
{
|
{
|
||||||
Job.CopyFrom(src.Job);
|
Job.CopyFrom(src.Job);
|
||||||
@ -462,48 +441,26 @@ public partial class JobViewModel : ObservableObject
|
|||||||
|
|
||||||
private void StepForward()
|
private void StepForward()
|
||||||
{
|
{
|
||||||
if (Segments.Count <= 1)
|
if (DurationSeconds <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var current = GetCurrentSegment();
|
var duration = SegmentDuration;
|
||||||
if ( current < 0 || current >= Segments.Count - 1 )
|
var segment = Math.Round(SliderLiveValue / duration, MidpointRounding.ToZero)+1;
|
||||||
return;
|
|
||||||
|
|
||||||
SliderLiveValue = Segments[current + 1].Start;
|
SliderLiveValue = Math.Min(DurationSeconds - duration, segment * duration);
|
||||||
|
// trigger seek in your playback pipeline here
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StepBackward()
|
private void StepBackward()
|
||||||
{
|
{
|
||||||
if (Segments.Count <= 0)
|
if (DurationSeconds <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var current = GetCurrentSegment();
|
var duration = SegmentDuration;
|
||||||
if (current <= 0)
|
var segment = Math.Max(0, Math.Round(SliderLiveValue / duration, MidpointRounding.ToZero)-1);
|
||||||
{
|
|
||||||
SliderLiveValue = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SliderLiveValue > Segments[current].Start)
|
SliderLiveValue = segment * duration;
|
||||||
SliderLiveValue = Segments[current].Start;
|
// trigger seek in your playback pipeline here
|
||||||
else
|
|
||||||
SliderLiveValue = Segments[current - 1].Start;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int GetCurrentSegment()
|
|
||||||
{
|
|
||||||
double pos = SliderLiveValue;
|
|
||||||
|
|
||||||
for (int i = 0; i < Segments.Count; i++)
|
|
||||||
{
|
|
||||||
var s = Segments[i];
|
|
||||||
if (pos < s.Start)
|
|
||||||
return i - 1;
|
|
||||||
if (pos == s.Start)
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSliderLiveValueChanged(double value)
|
partial void OnSliderLiveValueChanged(double value)
|
||||||
@ -526,15 +483,4 @@ public partial class JobViewModel : ObservableObject
|
|||||||
Task.Run(CreatePreview);
|
Task.Run(CreatePreview);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Avalonia.Media.Imaging.Bitmap?> GetThumbnail(double positionSec)
|
|
||||||
{
|
|
||||||
if (Probe == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var pos = TimeSpan.FromSeconds(positionSec);
|
|
||||||
var frame = await _thumbnails.CreateThumbnailAsync(Job.InputFile, Probe, pos, ThumbnailService.ThumbWidth, ThumbnailService.ThumbHeight, Job.Rotate).ConfigureAwait(false);
|
|
||||||
//frame.Save($"c:\\temp\\thmb-{positionSec:N4}.png");
|
|
||||||
return frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,17 +42,13 @@
|
|||||||
Maximum="{Binding Selected.DurationSeconds}"
|
Maximum="{Binding Selected.DurationSeconds}"
|
||||||
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
|
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
|
||||||
Margin="5,0,5,0" />
|
Margin="5,0,5,0" />
|
||||||
|
-->
|
||||||
<controls:PreviewSlider Grid.Column="1"
|
<controls:PreviewSlider Grid.Column="1"
|
||||||
Minimum="0"
|
Minimum="0"
|
||||||
Maximum="{Binding Selected.DurationSeconds}"
|
Maximum="{Binding Selected.DurationSeconds}"
|
||||||
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
|
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
|
||||||
SegmentDuration="{Binding Selected.SegmentDuration}"
|
SegmentDuration="{Binding Selected.SegmentDuration}"
|
||||||
Margin="5,0,5,0" />
|
Margin="5,0,5,0" />
|
||||||
-->
|
|
||||||
<controls:TimelinePreviewSlider Grid.Column="1"
|
|
||||||
ViewModel="{Binding Selected}"
|
|
||||||
Margin="5,0,5,0" />
|
|
||||||
|
|
||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user