Skip to content

Commit 9772b9a

Browse files
Add seek-bar event markers and a compact single-row control bar (#42)
* Add seek-bar event markers and a compact single-row control bar Surface the event.json metadata that was previously unused and tighten the playback chrome so the video gets more room. - Event marker: a reason-colored tick on the seek bar at the moment the incident happened (Sentry/Honk events land near the clip end), with a hover tooltip. Jump to it via a flag button or the E key. - Faint chunk-boundary ticks along the scrubber for at-a-glance structure. - Collapse the two-row controls into one row (transport / jump / scrubber / time / speed) and trim the camera-tile bar, giving the video ~75px more height. - "Select a clip to begin" hint replaces the blank idle panel. Adds SeekOffsetConverter and VM state (EventMarkerPosition, HasEventMarker, ChunkBoundaries, EventMarkerTooltip, JumpToEvent command) computed from the existing clip data, with unit tests. No data-model or Flyleaf changes. * Fix crash when hovering the scrubber The thumb's grow-on-hover storyboard (in the Slider ControlTemplate.Triggers) targeted ThumbScale by name, but ThumbScale lived inside the nested Thumb template's name scope, so IsMouseOver threw InvalidOperationException: "'ThumbScale' name cannot be found in the name scope of ControlTemplate". Move the ScaleTransform onto the named Thumb element itself so it shares the Slider template's name scope. Pre-existing since the #41 redesign.
1 parent 08fd14f commit 9772b9a

5 files changed

Lines changed: 545 additions & 134 deletions

File tree

SentryReplay.Tests/ConverterTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,37 @@ public void BoolToVisibilityConverter_CollapsesNonBooleanValues()
2424
converter.Convert(null, typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed);
2525
converter.Convert("true", typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed);
2626
}
27+
28+
[Fact]
29+
public void SeekOffsetConverter_ReturnsLeftOffsetOfFractionTimesWidth()
30+
{
31+
var converter = new SeekOffsetConverter();
32+
33+
var result = (Thickness)converter.Convert([0.5, 200d], typeof(Thickness), null, null);
34+
35+
result.Left.ShouldBe(100);
36+
result.Top.ShouldBe(0);
37+
}
38+
39+
[Theory]
40+
[InlineData(1.5, 200, 200)] // clamps above 1
41+
[InlineData(-0.5, 200, 0)] // clamps below 0
42+
public void SeekOffsetConverter_ClampsFraction(double fraction, double width, double expectedLeft)
43+
{
44+
var converter = new SeekOffsetConverter();
45+
46+
var result = (Thickness)converter.Convert([fraction, width], typeof(Thickness), null, null);
47+
48+
result.Left.ShouldBe(expectedLeft);
49+
}
50+
51+
[Fact]
52+
public void SeekOffsetConverter_ZeroWidthYieldsZeroOffset()
53+
{
54+
var converter = new SeekOffsetConverter();
55+
56+
var result = (Thickness)converter.Convert([0.5, 0d], typeof(Thickness), null, null);
57+
58+
result.ShouldBe(new Thickness(0));
59+
}
2760
}

SentryReplay.Tests/MainWindowViewModelTests.cs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,26 @@ private static CamClip ClipWithEvent(string name, string reason, string city, de
1616
[],
1717
new CamEvent { Reason = reason, City = city, EstLat = lat, EstLon = lon });
1818

19+
// A clip of one-minute chunks with an event at the given offset from the first chunk.
20+
private static CamClip ClipWithChunksAndEvent(int chunkCount, TimeSpan eventOffset, string reason = "user_interaction_honk")
21+
{
22+
var start = new DateTime(2025, 1, 1, 12, 0, 0);
23+
var chunks = Enumerable.Range(0, chunkCount)
24+
.Select(i => new CamChunk(start.AddMinutes(i), []))
25+
.ToList();
26+
var camEvent = new CamEvent { Reason = reason, Timestamp = start + eventOffset };
27+
return new CamClip(System.IO.Path.GetTempPath(), "Event Clip", start, chunks, camEvent);
28+
}
29+
30+
private static CamClip ClipWithChunks(int chunkCount)
31+
{
32+
var start = new DateTime(2025, 1, 1, 12, 0, 0);
33+
var chunks = Enumerable.Range(0, chunkCount)
34+
.Select(i => new CamChunk(start.AddMinutes(i), []))
35+
.ToList();
36+
return new CamClip(System.IO.Path.GetTempPath(), "Chunked Clip", start, chunks, camEvent: null);
37+
}
38+
1939
[Fact]
2040
public void NewViewModel_DefaultsToFrontCamera_AndEmptyOverlay()
2141
{
@@ -404,6 +424,175 @@ public async Task FilteredClips_NoMatch_IsEmpty()
404424
vm.FilteredClips.ShouldBeEmpty();
405425
}
406426

427+
// --- Event marker: the moment the incident happened, mapped onto the 0..1 seek axis ---
428+
429+
[Fact]
430+
public void EventMarker_NearClipEnd_MapsToHighFraction()
431+
{
432+
var vm = CreateViewModel();
433+
434+
// 10 one-minute chunks (600s modeled); event at 9m30s in -> 0.95.
435+
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
436+
437+
vm.HasEventMarker.ShouldBeTrue();
438+
vm.EventMarkerPosition.ShouldBe(0.95, 0.0001);
439+
vm.EventMarkerTooltip.ShouldStartWith("Honk · ");
440+
}
441+
442+
[Fact]
443+
public void EventMarker_AbsentWithoutEvent()
444+
{
445+
var vm = CreateViewModel();
446+
447+
vm.SelectedClip = ClipWithChunks(3);
448+
449+
vm.HasEventMarker.ShouldBeFalse();
450+
vm.EventMarkerPosition.ShouldBe(0);
451+
vm.EventMarkerTooltip.ShouldBeEmpty();
452+
}
453+
454+
[Fact]
455+
public void EventMarker_AbsentWhenEventTimestampIsDefault()
456+
{
457+
var vm = CreateViewModel();
458+
var chunks = ClipWithChunks(3).Chunks;
459+
var camEvent = new CamEvent { Reason = "user_interaction_honk" }; // Timestamp == default
460+
461+
vm.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "Default TS", new DateTime(2025, 1, 1, 12, 0, 0), chunks, camEvent);
462+
463+
vm.HasEventMarker.ShouldBeFalse();
464+
}
465+
466+
[Fact]
467+
public void EventMarker_AbsentWhenEventBeforeClipStart()
468+
{
469+
var vm = CreateViewModel();
470+
471+
// Clock skew: event five minutes before the first chunk -> fraction <= 0, no marker.
472+
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromMinutes(-5));
473+
474+
vm.HasEventMarker.ShouldBeFalse();
475+
}
476+
477+
[Fact]
478+
public void EventMarker_AbsentWhenEventBeyondModeledDuration()
479+
{
480+
var vm = CreateViewModel();
481+
482+
// 3 chunks = 180s modeled; an event at 200s is past the estimated end (fraction > 1).
483+
vm.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(200));
484+
485+
vm.HasEventMarker.ShouldBeFalse();
486+
}
487+
488+
[Fact]
489+
public void EventMarker_AbsentWhenNoChunks_NoDivideByZero()
490+
{
491+
var vm = CreateViewModel();
492+
var camEvent = new CamEvent { Reason = "user_interaction_honk", Timestamp = new DateTime(2025, 1, 1, 12, 5, 0) };
493+
494+
vm.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "No Chunks", new DateTime(2025, 1, 1, 12, 0, 0), [], camEvent);
495+
496+
vm.HasEventMarker.ShouldBeFalse();
497+
vm.EventMarkerPosition.ShouldBe(0);
498+
vm.ChunkBoundaries.ShouldBeEmpty();
499+
}
500+
501+
[Fact]
502+
public void ChunkBoundaries_AreInteriorFractions()
503+
{
504+
var vm = CreateViewModel();
505+
506+
vm.SelectedClip = ClipWithChunks(3);
507+
508+
vm.ChunkBoundaries.Count.ShouldBe(2);
509+
vm.ChunkBoundaries[0].ShouldBe(1.0 / 3, 0.0001);
510+
vm.ChunkBoundaries[1].ShouldBe(2.0 / 3, 0.0001);
511+
}
512+
513+
[Fact]
514+
public void ChunkBoundaries_EmptyForSingleChunk()
515+
{
516+
var vm = CreateViewModel();
517+
518+
vm.SelectedClip = ClipWithChunks(1);
519+
520+
vm.ChunkBoundaries.ShouldBeEmpty();
521+
}
522+
523+
[Fact]
524+
public void ClearingSelection_ResetsEventMarkerAndChunks()
525+
{
526+
var vm = CreateViewModel();
527+
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
528+
vm.HasEventMarker.ShouldBeTrue();
529+
530+
vm.SelectedClip = null;
531+
532+
vm.HasEventMarker.ShouldBeFalse();
533+
vm.EventMarkerPosition.ShouldBe(0);
534+
vm.ChunkBoundaries.ShouldBeEmpty();
535+
}
536+
537+
[Fact]
538+
public void JumpToEvent_CanExecute_FollowsHasEventMarker()
539+
{
540+
var vm = CreateViewModel();
541+
vm.JumpToEventCommand.CanExecute(null).ShouldBeFalse();
542+
543+
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
544+
545+
vm.JumpToEventCommand.CanExecute(null).ShouldBeTrue();
546+
}
547+
548+
[Fact]
549+
public async Task JumpToEvent_MovesSeekPositionToMarker()
550+
{
551+
var vm = CreateViewModel();
552+
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
553+
554+
await vm.JumpToEventCommand.ExecuteAsync(null);
555+
556+
vm.SeekPosition.ShouldBe(vm.EventMarkerPosition, 0.0001);
557+
}
558+
559+
[Fact]
560+
public async Task EventShortcut_JumpsToEvent_WhenMarkerPresent()
561+
{
562+
var vm = CreateViewModel();
563+
vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570));
564+
565+
var handled = await vm.HandleKeyDownAsync(Key.E, ModifierKeys.None);
566+
567+
handled.ShouldBeTrue();
568+
vm.SeekPosition.ShouldBe(vm.EventMarkerPosition, 0.0001);
569+
}
570+
571+
[Fact]
572+
public async Task EventShortcut_Ignored_WhenNoMarker()
573+
{
574+
var vm = CreateViewModel();
575+
vm.SelectedClip = ClipWithChunks(3); // no event
576+
577+
var handled = await vm.HandleKeyDownAsync(Key.E, ModifierKeys.None);
578+
579+
handled.ShouldBeFalse();
580+
}
581+
582+
[Fact]
583+
public void SelectingClip_RaisesEventMarkerNotifications()
584+
{
585+
var vm = CreateViewModel();
586+
var changed = new List<string>();
587+
vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
588+
589+
vm.SelectedClip = ClipWithChunksAndEvent(5, TimeSpan.FromSeconds(250));
590+
591+
changed.ShouldContain(nameof(MainWindowViewModel.EventMarkerPosition));
592+
changed.ShouldContain(nameof(MainWindowViewModel.HasEventMarker));
593+
changed.ShouldContain(nameof(MainWindowViewModel.ChunkBoundaries));
594+
}
595+
407596
// --- Playback: drive a real VideoPlayerController through FakeCameraPlayer (no Flyleaf/FFmpeg) ---
408597

409598
[Fact]

SentryReplay/ClipConverters.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,29 @@ public object[] ConvertBack(object value, Type[] targetTypes, object parameter,
210210
=> throw new NotSupportedException();
211211
}
212212

213+
/// <summary>
214+
/// Left offset for a seek-bar overlay (event marker or chunk tick): a left <see cref="Thickness"/>
215+
/// of position × rail width. [0] fraction (0..1), [1] rail ActualWidth. Mirrors
216+
/// <see cref="SeekFillWidthConverter"/> so overlays track the played fill and reflow on resize.
217+
/// </summary>
218+
public sealed class SeekOffsetConverter : MarkupExtension, IMultiValueConverter
219+
{
220+
public override object ProvideValue(IServiceProvider serviceProvider) => this;
221+
222+
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
223+
{
224+
if (values.Length == 2 && values[0] is double fraction && values[1] is double width && width > 0)
225+
{
226+
return new Thickness(Math.Clamp(fraction, 0, 1) * width, 0, 0, 0);
227+
}
228+
229+
return new Thickness(0);
230+
}
231+
232+
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
233+
=> throw new NotSupportedException();
234+
}
235+
213236
/// <summary>
214237
/// Visibility.Visible when the bound clip is the one currently playing.
215238
/// Multi-binding: [0] the clip, [1] the view-model's NowPlayingClip.

0 commit comments

Comments
 (0)