New timeline preview slider.

This commit is contained in:
Alexander Shabarshov 2026-06-20 09:38:48 +01:00
parent ddafb40ca7
commit f412db219f
4 changed files with 976 additions and 9 deletions

View File

@ -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<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;
// 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<Segment> 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<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(); }
}
}
}

View File

@ -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];

View File

@ -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<ParameterEntry> ParametersList { get; }
= new();
public ObservableCollection<Segment> 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<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;
}
}

View File

@ -42,13 +42,17 @@
Maximum="{Binding Selected.DurationSeconds}"
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
Margin="5,0,5,0" />
-->
<controls:PreviewSlider Grid.Column="1"
Minimum="0"
Maximum="{Binding Selected.DurationSeconds}"
Value="{Binding Selected.SliderLiveValue, Mode=TwoWay}"
SegmentDuration="{Binding Selected.SegmentDuration}"
Margin="5,0,5,0" />
-->
<controls:TimelinePreviewSlider Grid.Column="1"
ViewModel="{Binding Selected}"
Margin="5,0,5,0" />
<Button Grid.Column="2"
HorizontalAlignment="Right"