diff --git a/Splitter-UI/Controls/TimelinePreviewSlider.cs b/Splitter-UI/Controls/TimelinePreviewSlider.cs new file mode 100644 index 0000000..8dbea00 --- /dev/null +++ b/Splitter-UI/Controls/TimelinePreviewSlider.cs @@ -0,0 +1,931 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +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 ViewModelProperty = + AvaloniaProperty.Register(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 SegmentFillProperty = + AvaloniaProperty.Register(nameof(SegmentFill), Brushes.DimGray); + + public IBrush? SegmentFill + { + get => GetValue(SegmentFillProperty); + set => SetValue(SegmentFillProperty, value); + } + + public static readonly StyledProperty MarkerStrokeProperty = + AvaloniaProperty.Register(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 _previewCache = new(_maxPreviewCacheItems); + private readonly Dictionary _previewLoadCts = new(); + private readonly object _cacheLock = new(); + + private IDisposable? _segmentsSubscription; + + // Interaction state + private bool _isPointerCaptured; + private Point _lastPointerPoint; + private double _lastPointerXForDrag; // used to compute delta for segment drag + 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); + } + + InvalidateVisual(); + } + + private IDisposable SubscribeToSegments(ObservableCollection segments) + { + NotifyCollectionChangedEventHandler handler = (s, e) => + { + Dispatcher.UIThread.Post(() => { + InvalidateVisual(); + }, DispatcherPriority.Background); + }; + segments.CollectionChanged += handler; + return Disposable.Create(() => segments.CollectionChanged -= handler); + } + + private void UnsubscribeFromViewModel() + { + _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; + _lastPointerXForDrag = p.X; + _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; + } + + vm.SliderLiveValue = sec; + + 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); + _lastPointerXForDrag = _lastPointerPoint.X; + } + + 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; + var sec = PixelToSeconds(p.X); + sec = Math.Max(0, Math.Min(vm.DurationSeconds, sec)); + vm.SliderLiveValue = sec; + 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 where TKey : notnull where TValue : class + { + private readonly int _capacity; + private readonly Dictionary> _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>(); + _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(); } + } + } +} \ No newline at end of file diff --git a/Splitter-UI/Services/ThumbnailService.cs b/Splitter-UI/Services/ThumbnailService.cs index 66bcc34..d934ceb 100644 --- a/Splitter-UI/Services/ThumbnailService.cs +++ b/Splitter-UI/Services/ThumbnailService.cs @@ -7,11 +7,11 @@ namespace Splitter_UI.Services; public sealed class ThumbnailService : IThumbnailService { - private const int _thumbWidth = 160; - private const int _thumbHeight = 90; + public const int ThumbWidth = 160; + public const int ThumbHeight = 90; - private readonly byte [] _bgrBuffer = new byte[_thumbWidth * _thumbHeight * 3]; - private readonly byte [] _bgraBuffer = new byte[_thumbWidth * _thumbHeight * 4]; + private readonly byte [] _bgrBuffer = new byte[ThumbWidth * ThumbHeight * 3]; + private readonly byte [] _bgraBuffer = new byte[ThumbWidth * ThumbHeight * 4]; public SemaphoreSlim _lock = new(1,1); @@ -49,15 +49,15 @@ public sealed class ThumbnailService : IThumbnailService int? height = null, int? rotateDegree = null) { - width ??= _thumbWidth; - height ??= _thumbHeight; + width ??= ThumbWidth; + height ??= ThumbHeight; skip ??= TimeSpan.Zero; // buffer for BGR24 → 3 bytes per pixel var canUseStaticBuffers = - width.Value == _thumbWidth && - height.Value == _thumbHeight; + width.Value == ThumbWidth && + height.Value == ThumbHeight; var bgrBuffer = canUseStaticBuffers ? _bgrBuffer : new byte[width.Value * height.Value * 3]; var bgraBuffer = canUseStaticBuffers ? _bgraBuffer : new byte[width.Value * height.Value * 4]; diff --git a/Splitter-UI/ViewModels/JobViewModel.cs b/Splitter-UI/ViewModels/JobViewModel.cs index 2fb91d7..514688f 100644 --- a/Splitter-UI/ViewModels/JobViewModel.cs +++ b/Splitter-UI/ViewModels/JobViewModel.cs @@ -8,6 +8,8 @@ using CommunityToolkit.Mvvm.Input; namespace Splitter_UI.ViewModels; +public record Segment(double Start, double End); + public partial class JobViewModel : ObservableObject { private SingleJob Job { get; } @@ -70,6 +72,8 @@ public partial class JobViewModel : ObservableObject public ObservableCollection ParametersList { get; } = new(); + public ObservableCollection Segments { get; } = new(); + public string CropText { get => Job.Crop is { } c ? $"{c.width},{c.height}" : ""; @@ -311,6 +315,8 @@ public partial class JobViewModel : ObservableObject { if (e.PropertyName == nameof(Probe)) { + if (Segments.Count == 0) + GenerateSegments(); OnPropertyChanged(nameof(DurationSeconds)); } }; @@ -326,6 +332,21 @@ public partial class JobViewModel : ObservableObject _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) { Job.CopyFrom(src.Job); @@ -483,4 +504,15 @@ public partial class JobViewModel : ObservableObject Task.Run(CreatePreview); } + public async Task 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; + } + } diff --git a/Splitter-UI/Views/PreviewPane.axaml b/Splitter-UI/Views/PreviewPane.axaml index a32461d..abeaea6 100644 --- a/Splitter-UI/Views/PreviewPane.axaml +++ b/Splitter-UI/Views/PreviewPane.axaml @@ -42,13 +42,17 @@ Maximum="{Binding Selected.DurationSeconds}" Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}" Margin="5,0,5,0" /> ---> + +--> +