Skip to content

Commit b9597f5

Browse files
Live scrubbing: video follows the seek bar while dragging (#49)
1 parent f3730be commit b9597f5

10 files changed

Lines changed: 492 additions & 15 deletions

File tree

SentryReplay.Data/Playback/ICameraPlayer.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,12 @@ public interface ICameraPlayer : IDisposable
1616
Task PauseAsync();
1717
Task StopAsync();
1818
Task CloseAsync();
19-
Task SeekAsync(TimeSpan position);
19+
20+
/// <summary>
21+
/// Seeks to <paramref name="position"/>. Accurate seeks (the default) decode forward to land
22+
/// exactly on the target frame; fast seeks jump to the nearest keyframe, which is far cheaper
23+
/// but can land slightly before the target -- intended for live scrubbing while the seek bar
24+
/// thumb is being dragged, where responsiveness matters more than frame precision.
25+
/// </summary>
26+
Task SeekAsync(TimeSpan position, bool accurate = true);
2027
}

SentryReplay.Tests/Fixtures/FakeCameraPlayer.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ internal sealed class FakeCameraPlayer : ICameraPlayer
1414
public List<string> OpenedPaths { get; } = [];
1515
public List<TimeSpan> SeekPositions { get; } = [];
1616

17+
/// <summary>Ordered log of the <c>accurate</c> flag passed to each <see cref="SeekAsync"/> call.</summary>
18+
public List<bool> SeekAccurateFlags { get; } = [];
19+
1720
/// <summary>
1821
/// Ordered log of play/pause/seek calls so tests can assert on call ordering
1922
/// (e.g. that a post-recovery resume plays before it seeks).
@@ -98,10 +101,11 @@ public Task CloseAsync()
98101
return Task.CompletedTask;
99102
}
100103

101-
public Task SeekAsync(TimeSpan position)
104+
public Task SeekAsync(TimeSpan position, bool accurate = true)
102105
{
103106
SeekPositions.Add(position);
104-
CallLog.Add($"seek:{position.TotalSeconds}");
107+
SeekAccurateFlags.Add(accurate);
108+
CallLog.Add(accurate ? $"seek:{position.TotalSeconds}" : $"scrub:{position.TotalSeconds}");
105109
Position = position;
106110
PositionChanged?.Invoke(this, new CameraPositionChangedEventArgs(position));
107111
return Task.CompletedTask;

SentryReplay.Tests/MainWindowViewModelTests.cs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,123 @@ public async Task WhileScrubbing_ControllerPositionDoesNotMoveTheSlider()
649649
vm.SeekPosition.ShouldBe(0.25, 0.0001);
650650
}
651651

652+
// Synchronous (no async/await in the test body itself -- see CreateViewModelWithOpenedClip's
653+
// comment on why thread affinity matters here). FakeCameraPlayer's SeekAsync/OpenAsync and an
654+
// uncontended SemaphoreSlim all complete synchronously, so blocking on the resulting tasks
655+
// never suspends the thread and keeps every controller property change on it.
656+
[Fact]
657+
public void DragSequence_IssuesFastSeeks_ReleaseIssuesAccurateSeekAtReleasePosition()
658+
{
659+
using var clipFiles = TestClipFiles.Create(chunkCount: 1); // 60s clip (see TestClipFiles)
660+
var (vm, _, front) = CreateViewModelWithOpenedClip(UniquelyNamed(clipFiles));
661+
662+
vm.BeginSeek();
663+
664+
// Simulate a drag: each slider value change while dragging should scrub-seek (fast/keyframe).
665+
vm.SeekPosition = 0.2; // 12s of 60s
666+
vm.OnSeekSliderValueChanged();
667+
668+
vm.SeekPosition = 0.5; // 30s
669+
vm.OnSeekSliderValueChanged();
670+
671+
front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12));
672+
front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(30));
673+
674+
// Every seek issued so far while dragging must have been fast (non-accurate).
675+
front.SeekAccurateFlags.ShouldAllBe(accurate => accurate == false);
676+
677+
// Release at 0.75 (45s): EndSeekAsync must issue exactly one ACCURATE seek at the release position.
678+
vm.SeekPosition = 0.75;
679+
680+
// Blocking rather than awaiting is deliberate here, not an oversight: this whole test must stay
681+
// pinned to one thread (see CreateViewModelWithOpenedClip's comment), and EndSeekAsync only ever
682+
// awaits FakeCameraPlayer calls and an uncontended SemaphoreSlim, both of which complete
683+
// synchronously -- so this never actually blocks.
684+
#pragma warning disable xUnit1031
685+
vm.EndSeekAsync().GetAwaiter().GetResult();
686+
#pragma warning restore xUnit1031
687+
688+
front.SeekPositions[^1].ShouldBe(TimeSpan.FromSeconds(45));
689+
front.SeekAccurateFlags[^1].ShouldBeTrue();
690+
}
691+
692+
[Fact]
693+
public void PositionSync_WhenNotDragging_DoesNotTriggerScrubSeeks()
694+
{
695+
using var clipFiles = TestClipFiles.Create(chunkCount: 1);
696+
var (vm, controller, front) = CreateViewModelWithOpenedClip(UniquelyNamed(clipFiles));
697+
698+
front.SeekPositions.Clear();
699+
700+
// Playback position advances on its own (not a drag): SeekPosition updates via the
701+
// controller -> UpdateSeekPositionFromController path, which does not go through
702+
// OnSeekSliderValueChanged, so no scrub seek should ever be issued.
703+
controller.Position = TimeSpan.FromSeconds(10);
704+
vm.OnSeekSliderValueChanged(); // the view raises ValueChanged for programmatic changes too
705+
706+
front.SeekPositions.ShouldBeEmpty();
707+
}
708+
709+
// A generous 20s deadline (vs. the 5s used elsewhere in these test files): this helper waits on
710+
// a real clip-open flowing through Task.Run/the media source builder, which can slow down a lot
711+
// under the CPU/disk contention of the full suite's many parallel test classes; 20s comfortably
712+
// absorbs that while still catching a genuine hang.
713+
private static async Task WaitUntilAsync(Func<bool> condition)
714+
{
715+
var deadline = DateTime.UtcNow.AddSeconds(20);
716+
while (!condition())
717+
{
718+
if (DateTime.UtcNow > deadline)
719+
{
720+
throw new TimeoutException("Condition was not met within the timeout.");
721+
}
722+
723+
await Task.Delay(10);
724+
}
725+
}
726+
727+
// FfconcatMediaSourceBuilder writes each camera's playlist to a shared temp path keyed only by
728+
// the clip's Name (not its folder), and every TestClipFiles.Create() call across the whole
729+
// suite uses the same literal name ("Test Clip") -- so under full-suite parallel execution,
730+
// concurrently-running tests can race on the very same playlist file. Giving this test's clip
731+
// its own unique name sidesteps that shared-fixture collision without touching the fixture (or
732+
// the production path-naming) itself, which is shared by dozens of other tests.
733+
private static CamClip UniquelyNamed(TestClipFiles clipFiles)
734+
{
735+
var clip = clipFiles.Clip;
736+
return new CamClip(clip.FullPath, $"{clip.Name} {Guid.NewGuid():N}", clip.Timestamp, clip.Chunks, clip.Event);
737+
}
738+
739+
// Opens the clip on the controller to completion BEFORE the view-model subscribes to it, then
740+
// attaches the view-model. Doing it in this order (rather than via CreateViewModelWithController,
741+
// which subscribes up front) avoids the deadlock described on that helper: the real open flow's
742+
// ObservableProperty writes happen on background-thread continuations, and if the view-model were
743+
// already subscribed, its PropertyChanged handler would call Dispatcher.Invoke from that background
744+
// thread with no pumped message loop to service it, hanging the open forever. Once the clip is
745+
// fully open and idle, driving the view-model's own seek APIs from the test thread afterward is safe.
746+
// Synchronous by design (no async/await): the view-model's constructor captures
747+
// Dispatcher.CurrentDispatcher for whatever thread calls it, and RunOnUiThread only stays
748+
// deadlock-free while every later controller property change happens on that exact same
749+
// thread (see the comment on CreateViewModelWithController above). An async method resumes
750+
// its continuation after an await on a thread-pool thread with no guarantee it matches the
751+
// thread that ran the code before the await -- which silently breaks that invariant. Blocking
752+
// on the wait here (instead of awaiting it) keeps everything, including the VM construction
753+
// and every later test action, pinned to the single calling thread.
754+
private static (MainWindowViewModel Vm, VideoPlayerController Controller, FakeCameraPlayer Front) CreateViewModelWithOpenedClip(
755+
CamClip clip)
756+
{
757+
var front = new FakeCameraPlayer();
758+
var built = new VideoPlayerController(front, new FakeCameraPlayer(), new FakeCameraPlayer(), new FakeCameraPlayer());
759+
760+
built.LoadClips([clip]);
761+
built.Playlist.MoveTo(0);
762+
WaitUntilAsync(() => front.PlayCount > 0 && built.IsMediaOpen && !built.IsLoading).GetAwaiter().GetResult();
763+
764+
var vm = new MainWindowViewModel(() => built, backgroundYield: () => Task.CompletedTask);
765+
vm.InitializePlayer();
766+
return (vm, built, front);
767+
}
768+
652769
[Fact]
653770
public void ControllerLoadingAndPlaying_MirrorToViewModel()
654771
{
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
namespace SentryReplay.Tests;
2+
3+
public sealed class SeekScrubCoalescerTests
4+
{
5+
[Fact]
6+
public void FirstValue_IssuesImmediately()
7+
{
8+
var issued = new List<TimeSpan>();
9+
var coalescer = new SeekScrubCoalescer(position =>
10+
{
11+
issued.Add(position);
12+
return Task.CompletedTask;
13+
});
14+
15+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(1));
16+
17+
issued.ShouldBe([TimeSpan.FromSeconds(1)]);
18+
}
19+
20+
[Fact]
21+
public async Task RapidBurst_WhileSeekInFlight_DropsIntermediatesAndIssuesOnlyLastValueOnCompletion()
22+
{
23+
var issued = new List<TimeSpan>();
24+
var gate = new TaskCompletionSource();
25+
var coalescer = new SeekScrubCoalescer(async position =>
26+
{
27+
issued.Add(position);
28+
29+
// The first seek blocks so the burst below all lands while it's in flight.
30+
if (issued.Count == 1)
31+
{
32+
await gate.Task;
33+
}
34+
});
35+
36+
// First value issues immediately and blocks on the gate.
37+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(1));
38+
issued.ShouldBe([TimeSpan.FromSeconds(1)]);
39+
40+
// A rapid burst of further values arrives while the first seek is still in flight; all but
41+
// the last are dropped entirely (never issued, not even queued).
42+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(1.3));
43+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(1.6));
44+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(1.9));
45+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(2.5));
46+
47+
issued.ShouldBe([TimeSpan.FromSeconds(1)]); // still only the first -- nothing issued yet
48+
49+
// Let the first seek complete; the coalescer should now issue exactly the last queued value.
50+
gate.SetResult();
51+
await WaitUntilAsync(() => issued.Count == 2);
52+
53+
issued.ShouldBe([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2.5)]);
54+
}
55+
56+
[Fact]
57+
public void ValueWithinMinimumStep_OfLastIssuedValue_IsSkipped()
58+
{
59+
var issued = new List<TimeSpan>();
60+
var coalescer = new SeekScrubCoalescer(
61+
position =>
62+
{
63+
issued.Add(position);
64+
return Task.CompletedTask;
65+
},
66+
minimumStep: TimeSpan.FromMilliseconds(250));
67+
68+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(10));
69+
issued.ShouldBe([TimeSpan.FromSeconds(10)]);
70+
71+
// Well within 250ms of the last-issued value -- skipped, no new seek.
72+
coalescer.OnDragValueChanged(TimeSpan.FromMilliseconds(10100));
73+
issued.ShouldBe([TimeSpan.FromSeconds(10)]);
74+
75+
// Clears the 250ms threshold -- issued.
76+
coalescer.OnDragValueChanged(TimeSpan.FromMilliseconds(10300));
77+
issued.ShouldBe([TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(10300)]);
78+
}
79+
80+
[Fact]
81+
public async Task PendingValueWithinMinimumStep_OfLastIssuedValue_IsNotReissuedOnCompletion()
82+
{
83+
var issued = new List<TimeSpan>();
84+
var gate = new TaskCompletionSource();
85+
var coalescer = new SeekScrubCoalescer(async position =>
86+
{
87+
issued.Add(position);
88+
if (issued.Count == 1)
89+
{
90+
await gate.Task;
91+
}
92+
});
93+
94+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(5));
95+
96+
// Queued while in flight, but only 100ms away from the last-issued value.
97+
coalescer.OnDragValueChanged(TimeSpan.FromMilliseconds(5100));
98+
99+
gate.SetResult();
100+
101+
// Give the continuation a chance to run; it must NOT issue a second seek.
102+
await Task.Delay(50);
103+
104+
issued.ShouldBe([TimeSpan.FromSeconds(5)]);
105+
coalescer.IsSeekInFlight.ShouldBeFalse();
106+
}
107+
108+
[Fact]
109+
public void Reset_ForgetsLastIssuedValue_SoNextValueAlwaysIssues()
110+
{
111+
var issued = new List<TimeSpan>();
112+
var coalescer = new SeekScrubCoalescer(position =>
113+
{
114+
issued.Add(position);
115+
return Task.CompletedTask;
116+
});
117+
118+
coalescer.OnDragValueChanged(TimeSpan.FromSeconds(10));
119+
coalescer.Reset();
120+
121+
// Even though this is well within 250ms of the previous value, Reset cleared history for
122+
// the new drag, so it must issue.
123+
coalescer.OnDragValueChanged(TimeSpan.FromMilliseconds(10050));
124+
125+
issued.ShouldBe([TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(10050)]);
126+
}
127+
128+
private static async Task WaitUntilAsync(Func<bool> condition)
129+
{
130+
var deadline = DateTime.UtcNow.AddSeconds(5);
131+
while (!condition())
132+
{
133+
if (DateTime.UtcNow > deadline)
134+
{
135+
throw new TimeoutException("Condition was not met within the timeout.");
136+
}
137+
138+
await Task.Delay(10);
139+
}
140+
}
141+
}

SentryReplay.Tests/VideoPlayerControllerTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,43 @@ public async Task PauseSeekAndStop_ControlOpenPlayers()
9999
controller.IsMediaOpen.ShouldBeFalse();
100100
}
101101

102+
[Fact]
103+
public async Task ScrubSeekAsync_IssuesFastSeeksToOpenPlayers()
104+
{
105+
using var clipFiles = TestClipFiles.Create(chunkCount: 1);
106+
var front = new FakeCameraPlayer();
107+
var back = new FakeCameraPlayer();
108+
using var controller = CreateController(front, back);
109+
110+
controller.LoadClips([clipFiles.Clip]);
111+
controller.Playlist.MoveTo(0);
112+
await WaitUntilAsync(() => front.PlayCount > 0 && back.PlayCount > 0);
113+
114+
await controller.ScrubSeekAsync(TimeSpan.FromSeconds(12));
115+
116+
front.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12));
117+
back.SeekPositions.ShouldContain(TimeSpan.FromSeconds(12));
118+
front.SeekAccurateFlags[^1].ShouldBeFalse();
119+
back.SeekAccurateFlags[^1].ShouldBeFalse();
120+
controller.Position.ShouldBe(TimeSpan.FromSeconds(12));
121+
}
122+
123+
[Fact]
124+
public async Task SeekAsync_IssuesAccurateSeeksToOpenPlayers()
125+
{
126+
using var clipFiles = TestClipFiles.Create(chunkCount: 1);
127+
var front = new FakeCameraPlayer();
128+
using var controller = CreateController(front);
129+
130+
controller.LoadClips([clipFiles.Clip]);
131+
controller.Playlist.MoveTo(0);
132+
await WaitUntilAsync(() => front.PlayCount > 0);
133+
134+
await controller.SeekAsync(TimeSpan.FromSeconds(12));
135+
136+
front.SeekAccurateFlags[^1].ShouldBeTrue();
137+
}
138+
102139
[Fact]
103140
public async Task StopAsync_ClosesPlayersEvenWhenStopFails()
104141
{

SentryReplay/MainWindow.xaml.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ private async void SeekSlider_PreviewMouseUp(object sender, MouseButtonEventArgs
155155
await _viewModel.EndSeekAsync();
156156
}
157157

158+
// Fires for both thumb-drag and click-then-drag (WPF raises ValueChanged on every Value mutation,
159+
// whether from dragging the Thumb or from IsMoveToPointEnabled's click-to-position), and also for
160+
// the one-off value jump a plain click makes. PreviewMouseDown has already called BeginSeek by
161+
// the time this fires, so even a plain click issues one keyframe scrub seek here — harmless,
162+
// since the accurate mouse-up seek runs behind the same serialized lock and lands last.
158163
private void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
159164
{
160165
_viewModel.OnSeekSliderValueChanged();

0 commit comments

Comments
 (0)