From d614b2bb88e49f353b34e82dc0df5cb0bb458caa Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 13:39:10 -0500 Subject: [PATCH 01/12] Load clips off the UI thread --- .../MainWindowViewModelTests.cs | 20 ++--- SentryReplay/MainWindowViewModel.cs | 76 +++++++++++++------ 2 files changed, 64 insertions(+), 32 deletions(-) diff --git a/SentryReplay.Tests/MainWindowViewModelTests.cs b/SentryReplay.Tests/MainWindowViewModelTests.cs index c6ae510..2833110 100644 --- a/SentryReplay.Tests/MainWindowViewModelTests.cs +++ b/SentryReplay.Tests/MainWindowViewModelTests.cs @@ -279,22 +279,22 @@ public void DismissError_ClearsErrorState() // --- Clip browsing: the injectable clip loader lets us populate clips without disk I/O --- [Fact] - public void FilteredClips_OrderNewestFirst() + public async Task FilteredClips_OrderNewestFirst() { var clips = TestClips.Create(3); // timestamps increase with index var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - vm.LoadClips(new[] { "root" }); + await vm.LoadClipsAsync(new[] { "root" }); vm.FilteredClips.Select(c => c.Name).ShouldBe(new[] { "Clip 2", "Clip 1", "Clip 0" }); } [Fact] - public void FilteredClips_FiltersByNameCaseInsensitively() + public async Task FilteredClips_FiltersByNameCaseInsensitively() { var clips = TestClips.Create(3); var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - vm.LoadClips(new[] { "root" }); + await vm.LoadClipsAsync(new[] { "root" }); vm.FilterText = "clip 1"; @@ -302,12 +302,12 @@ public void FilteredClips_FiltersByNameCaseInsensitively() } [Fact] - public void FilteredClips_FiltersByPath() + public async Task FilteredClips_FiltersByPath() { // TestClips share a folder path but have distinct names, so a path-only match keeps them all. var clips = TestClips.Create(2); var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - vm.LoadClips(new[] { "root" }); + await vm.LoadClipsAsync(new[] { "root" }); vm.FilterText = clips[0].FullPath; @@ -315,11 +315,11 @@ public void FilteredClips_FiltersByPath() } [Fact] - public void FilteredClips_NoMatch_IsEmpty() + public async Task FilteredClips_NoMatch_IsEmpty() { var clips = TestClips.Create(3); var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - vm.LoadClips(new[] { "root" }); + await vm.LoadClipsAsync(new[] { "root" }); vm.FilterText = "no-such-clip"; @@ -410,8 +410,8 @@ public void ControllerError_ShowsErrorOverlay() [Fact] public void CanGoNextPrevious_ReflectControllerPlaylist() { - var vm = CreateViewModelWithController(out _, out _, clipLoader: _ => TestClips.Create(3)); - vm.LoadClips(new[] { "root" }); + var vm = CreateViewModelWithController(out var controller, out _); + controller.LoadClips(TestClips.Create(3)); // set the playlist directly (synchronous, on the test thread) // Playlist loaded, nothing playing yet: can advance, can't go back. vm.CanGoNext.ShouldBeTrue(); diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index 8315842..6212a34 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -274,7 +274,7 @@ public async Task InitializeAsync() if (_flyleafRuntime.TryStart()) { InitializePlayer(); - LoadClips(CamStorage.FindCommonRoots()); + await LoadClipsAsync(CamStorage.FindCommonRoots()); } else { @@ -304,27 +304,58 @@ public void InitializePlayer() _playerController.PlaybackSpeed = SelectedPlaybackSpeed; } - public void LoadClips(IEnumerable roots) + public async Task LoadClipsAsync(IEnumerable roots) { ClearError(); _allClips.Clear(); SelectedClip = null; + IsLoading = true; + RefreshClipState(); + + try + { + // Scan the disk off the UI thread; the continuation resumes on it via the WPF + // SynchronizationContext, so all view-model state below is mutated on the UI thread. + var result = await Task.Run(() => ScanRoots(roots)); + + if (!result.HadRoots) + { + ShowError( + "No Dashcam Folders Found", + "Click 'Select Folder' to choose a folder containing Tesla dashcam footage (TeslaCam folder).", + canDismiss: true); + } + else + { + _allClips.AddRange(result.Clips); + foreach (var error in result.Errors) + { + ShowError(error.Title, error.Details); + } + } + _playerController?.LoadClips(_allClips); + } + finally + { + IsLoading = false; + RefreshClipState(); + } + } + + private ScanResult ScanRoots(IEnumerable roots) + { var rootList = roots?.Where(root => !string.IsNullOrWhiteSpace(root)).ToList() ?? []; if (rootList.Count == 0) { Log.Information("No dashcam roots found"); - ShowError( - "No Dashcam Folders Found", - "Click 'Select Folder' to choose a folder containing Tesla dashcam footage (TeslaCam folder).", - canDismiss: true); - RefreshClipState(); - return; + return new ScanResult([], [], HadRoots: false); } Log.Information("Loading dashcam clips. RootCount={RootCount}; Roots={Roots}", rootList.Count, rootList); var totalStopwatch = Stopwatch.StartNew(); - var failedRoots = 0; + var clips = new List(); + var errors = new List(); foreach (var root in rootList) { @@ -333,38 +364,39 @@ public void LoadClips(IEnumerable roots) try { - var clips = _clipLoader(root); - _allClips.AddRange(clips); + var rootClips = _clipLoader(root); + clips.AddRange(rootClips); Log.Information( "Scanned dashcam root. Root={Root}; ClipCount={ClipCount}; ElapsedMs={ElapsedMs}", root, - clips.Count, + rootClips.Count, rootStopwatch.ElapsedMilliseconds); } catch (UnauthorizedAccessException ex) { - failedRoots++; Log.Error(ex, "Access denied while loading dashcam root. Root={Root}", root); - ShowError("Access Denied", $"Cannot access folder: {root}\n\nCheck that you have permission to read this location."); + errors.Add(new ClipLoadError("Access Denied", $"Cannot access folder: {root}\n\nCheck that you have permission to read this location.")); } catch (Exception ex) { - failedRoots++; Log.Error(ex, "Failed to load dashcam root. Root={Root}", root); - ShowError("Error Loading Clips", $"Failed to load clips from:\n{root}\n\nError: {ex.Message}"); + errors.Add(new ClipLoadError("Error Loading Clips", $"Failed to load clips from:\n{root}\n\nError: {ex.Message}")); } } - _playerController?.LoadClips(_allClips); - RefreshClipState(); Log.Information( "Finished loading dashcam clips. ClipCount={ClipCount}; RootCount={RootCount}; FailedRootCount={FailedRootCount}; ElapsedMs={ElapsedMs}", - _allClips.Count, + clips.Count, rootList.Count, - failedRoots, + errors.Count, totalStopwatch.ElapsedMilliseconds); + return new ScanResult(clips, errors, HadRoots: true); } + private sealed record ScanResult(IReadOnlyList Clips, IReadOnlyList Errors, bool HadRoots); + + private sealed record ClipLoadError(string Title, string Details); + private void RefreshClipState() { OnPropertyChanged(nameof(FilteredClips)); @@ -399,7 +431,7 @@ private async Task OpenFolderAsync() await _playerController.StopAsync(); } - LoadClips(dialog.FolderNames); + await LoadClipsAsync(dialog.FolderNames); } else { @@ -459,7 +491,7 @@ private async Task DownloadFFmpegAsync() if (_flyleafRuntime.TryStart()) { InitializePlayer(); - LoadClips(CamStorage.FindCommonRoots()); + await LoadClipsAsync(CamStorage.FindCommonRoots()); } else { From 07d8080910171760430d9da5883d10621dadf7ac Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 13:39:11 -0500 Subject: [PATCH 02/12] Add fluid UI animations --- SentryReplay/MainWindow.xaml | 154 ++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 4 deletions(-) diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index 977c49a..8186b81 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -30,9 +30,14 @@ - + CornerRadius="4" + RenderTransformOrigin="0.5,0.5"> + + + @@ -42,6 +47,34 @@ + + + + + + + + + + + + + + + + @@ -80,12 +113,44 @@ Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" - CornerRadius="6"> + CornerRadius="6" + RenderTransformOrigin="0.5,0.5"> + + + + + + + + + + + + + + + + + + + @@ -147,6 +212,24 @@ + + + + + + + + + + + + + + + @@ -204,9 +287,26 @@ Margin="4,0" Background="Transparent" BorderThickness="0" - ItemsSource="{Binding FilteredClips}" + ItemsSource="{Binding FilteredClips, NotifyOnTargetUpdated=True}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" SelectedItem="{Binding SelectedClip, Mode=TwoWay}"> + + + + + + + + + + + + + + + @@ -822,6 +945,29 @@ + + + From 43df065016a9b1b14e6b9f09ac9b6bc6c9354709 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 13:57:18 -0500 Subject: [PATCH 03/12] Stop the clip list re-rendering on every clip change --- SentryReplay/MainWindowViewModel.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index 6212a34..a773587 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -339,6 +339,7 @@ public async Task LoadClipsAsync(IEnumerable roots) finally { IsLoading = false; + OnPropertyChanged(nameof(FilteredClips)); RefreshClipState(); } } @@ -399,7 +400,9 @@ private sealed record ClipLoadError(string Title, string Details); private void RefreshClipState() { - OnPropertyChanged(nameof(FilteredClips)); + // FilteredClips is intentionally NOT raised here: this runs on every clip change, and + // re-notifying the unchanged list rebuilds the ListBox and retriggers its fade (flicker). + // The list is notified explicitly only when it actually changes (load + FilterText). OnPropertyChanged(nameof(HasNoClipSelected)); OnPropertyChanged(nameof(ShowStatusOverlay)); OnPropertyChanged(nameof(ShowVideoHosts)); From f1d6bdbc7e8de08dde4ac621e2f08ef5dcc77fb5 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 13:57:18 -0500 Subject: [PATCH 04/12] Reveal the first frame before starting playback --- .../Playback/VideoPlayerController.cs | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/SentryReplay/Playback/VideoPlayerController.cs b/SentryReplay/Playback/VideoPlayerController.cs index f9be362..056a609 100644 --- a/SentryReplay/Playback/VideoPlayerController.cs +++ b/SentryReplay/Playback/VideoPlayerController.cs @@ -387,7 +387,9 @@ await RunSerializedPlaybackOperationAsync(async ct => _timeline.Count, Duration, requestId); - await OpenChunkInternalAsync(clip, chunkIndex: 0, offset: TimeSpan.Zero, playAfterOpen: true, requestId, ct); + // Open paused so every camera decodes its first frame before the loading overlay clears; + // playback is started below, after the reveal, so the clip flows from a still frame into motion. + await OpenChunkInternalAsync(clip, chunkIndex: 0, offset: TimeSpan.Zero, playAfterOpen: false, requestId, ct); }, replacePlaybackCts: true); } catch (OperationCanceledException) @@ -410,6 +412,26 @@ await RunSerializedPlaybackOperationAsync(async ct => IsLoading = false; } } + + // The first frame is decoded and the overlay has cleared; start playback now so the revealed + // still frame flows straight into motion instead of visibly loading in after the overlay is gone. + if (IsRequestActive(requestId) && IsMediaOpen && _openedClip == clip) + { + try + { + await RunSerializedPlaybackOperationAsync(async _ => + { + if (!IsRequestActive(requestId) || _openedClip != clip) + return; + + await PlayOpenPlayersAsync(); + IsPlaying = true; + }); + } + catch (OperationCanceledException) + { + } + } } private async Task StopPlaybackInternalAsync(bool resetTimeline, bool clearLoading = true) @@ -541,7 +563,7 @@ private async Task OpenChunkInternalAsync( } else { - await PauseOpenPlayersAsync(); + // Freshly opened players are already paused (AutoPlay is off), so just reflect the state. IsPlaying = false; } From 412e7a3e1b76553261655767640b8e0f840aefbf Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 14:20:18 -0500 Subject: [PATCH 05/12] Hide clip load-in behind per-host video covers --- SentryReplay/MainWindow.xaml | 33 +++++++++++++++++++++++++---- SentryReplay/MainWindow.xaml.cs | 26 +++++++++++++++++++++-- SentryReplay/MainWindowViewModel.cs | 7 ++++-- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index 8186b81..b82fd0b 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -443,10 +443,35 @@ - - - - + + + + + + + + + + + + + diff --git a/SentryReplay/MainWindow.xaml.cs b/SentryReplay/MainWindow.xaml.cs index e4f67de..ae318c0 100644 --- a/SentryReplay/MainWindow.xaml.cs +++ b/SentryReplay/MainWindow.xaml.cs @@ -3,6 +3,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; +using System.Windows.Media.Animation; using System.Windows.Navigation; using FlyleafLib.Controls.WPF; using Serilog; @@ -101,9 +102,30 @@ private void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventAr private void ViewModelOnPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(MainWindowViewModel.SelectedCameraView)) + switch (e.PropertyName) { - UpdateCameraHostLayout(); + case nameof(MainWindowViewModel.SelectedCameraView): + UpdateCameraHostLayout(); + break; + + case nameof(MainWindowViewModel.IsLoading): + UpdateVideoCovers(_viewModel.IsLoading); + break; + } + } + + // Fades the per-host covers (FlyleafHost overlay content) so the player's first-frame load-in happens + // behind an opaque cover, then reveals the ready frame. Covers snap on quickly and reveal gently. + private void UpdateVideoCovers(bool covered) + { + var animation = new DoubleAnimation(covered ? 1.0 : 0.0, new Duration(TimeSpan.FromMilliseconds(covered ? 60 : 280))) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + }; + + foreach (var cover in new[] { FrontVideoCover, BackVideoCover, LeftVideoCover, RightVideoCover }) + { + cover.BeginAnimation(OpacityProperty, animation); } } diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index a773587..2916dfd 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -130,9 +130,12 @@ public string PositionText public string PlayPauseIcon => IsPlaying ? "⏸" : "▶"; - public bool ShowStatusOverlay => IsLoading || ShowErrorOverlay || HasNoClipSelected; + // The full-screen WPF overlay only covers the no-video states (scanning with no clip, error, empty), + // because as a WPF sibling it can't draw over the Flyleaf surface anyway. A selected clip's own load is + // hidden by the per-host covers (FlyleafHost overlay content) instead, so the hosts stay visible. + public bool ShowStatusOverlay => (IsLoading && SelectedClip is null) || ShowErrorOverlay || HasNoClipSelected; - public bool ShowVideoHosts => !ShowStatusOverlay; + public bool ShowVideoHosts => SelectedClip is not null && !ShowErrorOverlay; public bool HasError => ShowErrorOverlay; From a9734d22e343d1287ec58ec4cc12a95d1efe9ea2 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 14:30:46 -0500 Subject: [PATCH 06/12] Show players directly during clip load; drop the cover machinery --- SentryReplay/MainWindow.xaml | 33 +++---------------- SentryReplay/MainWindow.xaml.cs | 26 ++------------- SentryReplay/MainWindowViewModel.cs | 6 ++-- .../Playback/VideoPlayerController.cs | 26 ++------------- 4 files changed, 11 insertions(+), 80 deletions(-) diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index b82fd0b..8186b81 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -443,35 +443,10 @@ - - - - - - - - - - - - - + + + + diff --git a/SentryReplay/MainWindow.xaml.cs b/SentryReplay/MainWindow.xaml.cs index ae318c0..e4f67de 100644 --- a/SentryReplay/MainWindow.xaml.cs +++ b/SentryReplay/MainWindow.xaml.cs @@ -3,7 +3,6 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Media.Animation; using System.Windows.Navigation; using FlyleafLib.Controls.WPF; using Serilog; @@ -102,30 +101,9 @@ private void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventAr private void ViewModelOnPropertyChanged(object sender, PropertyChangedEventArgs e) { - switch (e.PropertyName) + if (e.PropertyName == nameof(MainWindowViewModel.SelectedCameraView)) { - case nameof(MainWindowViewModel.SelectedCameraView): - UpdateCameraHostLayout(); - break; - - case nameof(MainWindowViewModel.IsLoading): - UpdateVideoCovers(_viewModel.IsLoading); - break; - } - } - - // Fades the per-host covers (FlyleafHost overlay content) so the player's first-frame load-in happens - // behind an opaque cover, then reveals the ready frame. Covers snap on quickly and reveal gently. - private void UpdateVideoCovers(bool covered) - { - var animation = new DoubleAnimation(covered ? 1.0 : 0.0, new Duration(TimeSpan.FromMilliseconds(covered ? 60 : 280))) - { - EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, - }; - - foreach (var cover in new[] { FrontVideoCover, BackVideoCover, LeftVideoCover, RightVideoCover }) - { - cover.BeginAnimation(OpacityProperty, animation); + UpdateCameraHostLayout(); } } diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index 2916dfd..9d513b1 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -130,9 +130,9 @@ public string PositionText public string PlayPauseIcon => IsPlaying ? "⏸" : "▶"; - // The full-screen WPF overlay only covers the no-video states (scanning with no clip, error, empty), - // because as a WPF sibling it can't draw over the Flyleaf surface anyway. A selected clip's own load is - // hidden by the per-host covers (FlyleafHost overlay content) instead, so the hosts stay visible. + // The full-screen overlay only covers the no-video states (scanning with no clip, error, empty); as a WPF + // sibling it can't draw over the Flyleaf video surface anyway. While a selected clip loads, the hosts stay + // visible and simply show black until the first frame decodes — no loading screen flashing mid-playback. public bool ShowStatusOverlay => (IsLoading && SelectedClip is null) || ShowErrorOverlay || HasNoClipSelected; public bool ShowVideoHosts => SelectedClip is not null && !ShowErrorOverlay; diff --git a/SentryReplay/Playback/VideoPlayerController.cs b/SentryReplay/Playback/VideoPlayerController.cs index 056a609..f9be362 100644 --- a/SentryReplay/Playback/VideoPlayerController.cs +++ b/SentryReplay/Playback/VideoPlayerController.cs @@ -387,9 +387,7 @@ await RunSerializedPlaybackOperationAsync(async ct => _timeline.Count, Duration, requestId); - // Open paused so every camera decodes its first frame before the loading overlay clears; - // playback is started below, after the reveal, so the clip flows from a still frame into motion. - await OpenChunkInternalAsync(clip, chunkIndex: 0, offset: TimeSpan.Zero, playAfterOpen: false, requestId, ct); + await OpenChunkInternalAsync(clip, chunkIndex: 0, offset: TimeSpan.Zero, playAfterOpen: true, requestId, ct); }, replacePlaybackCts: true); } catch (OperationCanceledException) @@ -412,26 +410,6 @@ await RunSerializedPlaybackOperationAsync(async ct => IsLoading = false; } } - - // The first frame is decoded and the overlay has cleared; start playback now so the revealed - // still frame flows straight into motion instead of visibly loading in after the overlay is gone. - if (IsRequestActive(requestId) && IsMediaOpen && _openedClip == clip) - { - try - { - await RunSerializedPlaybackOperationAsync(async _ => - { - if (!IsRequestActive(requestId) || _openedClip != clip) - return; - - await PlayOpenPlayersAsync(); - IsPlaying = true; - }); - } - catch (OperationCanceledException) - { - } - } } private async Task StopPlaybackInternalAsync(bool resetTimeline, bool clearLoading = true) @@ -563,7 +541,7 @@ private async Task OpenChunkInternalAsync( } else { - // Freshly opened players are already paused (AutoPlay is off), so just reflect the state. + await PauseOpenPlayersAsync(); IsPlaying = false; } From 7ee80fe6eaa36dadf214704bfbc86383f613dc1f Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 14:44:52 -0500 Subject: [PATCH 07/12] Fix camera-view switch flash (drop blanket reparent + force layout) --- SentryReplay/MainWindow.xaml.cs | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/SentryReplay/MainWindow.xaml.cs b/SentryReplay/MainWindow.xaml.cs index e4f67de..f400c38 100644 --- a/SentryReplay/MainWindow.xaml.cs +++ b/SentryReplay/MainWindow.xaml.cs @@ -118,8 +118,6 @@ private void UpdateCameraHostLayout() if (PrimaryCameraHostSlot is null) return; - ClearCameraHostSlots(); - switch (_viewModel.SelectedCameraView) { case MainWindowViewModel.GridCameraView: @@ -157,30 +155,11 @@ private void UpdateCameraHostLayout() MoveHostToSlot(RightFlyleafHost, RightTileHostSlot); break; } - } - private void ClearCameraHostSlots() - { - ContentControl[] slots = - [ - PrimaryCameraHostSlot, - GridFrontHostSlot, - GridRearHostSlot, - GridLeftHostSlot, - GridRightHostSlot, - FrontTileHostSlot, - RearTileHostSlot, - LeftTileHostSlot, - RightTileHostSlot, - ]; - - foreach (var slot in slots) - { - if (slot.Content is FlyleafHost) - { - slot.Content = null; - } - } + // Force a synchronous layout pass so each moved host gets its final bounds before the next render + // frame. The Flyleaf surface follows the host via its LayoutUpdated event, so without this it would + // briefly render at a stale/default rect (a small player flashing in the top-left) before snapping. + UpdateLayout(); } private static void MoveHostToSlot(FlyleafHost host, ContentControl slot) From 15e298414c7e912c5ed6c39dc1b7585e65e71527 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 14:52:09 -0500 Subject: [PATCH 08/12] Remove camera-tile hover scale (shifted mini video); add clip-sidebar loading bar --- SentryReplay/MainWindow.xaml | 46 ++++++++--------------------- SentryReplay/MainWindowViewModel.cs | 6 ++++ 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index 8186b81..185225b 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -113,44 +113,14 @@ Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" - CornerRadius="6" - RenderTransformOrigin="0.5,0.5"> - - - + CornerRadius="6"> + - - - - - - - - - - - - - - - - @@ -395,6 +365,16 @@ + + + diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index 9d513b1..e047e27 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -220,6 +220,10 @@ public string PositionText [NotifyPropertyChangedFor(nameof(IsIndeterminateProgress))] private bool _isRendering; + // True while the clip list is being (re)scanned from disk; drives the sidebar loading indicator. + [ObservableProperty] + private bool _isLoadingClips; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(RenderProgressPercent))] [NotifyPropertyChangedFor(nameof(LoadingStatusText))] @@ -313,6 +317,7 @@ public async Task LoadClipsAsync(IEnumerable roots) _allClips.Clear(); SelectedClip = null; IsLoading = true; + IsLoadingClips = true; RefreshClipState(); try @@ -342,6 +347,7 @@ public async Task LoadClipsAsync(IEnumerable roots) finally { IsLoading = false; + IsLoadingClips = false; OnPropertyChanged(nameof(FilteredClips)); RefreshClipState(); } From 4f780b272a608a0bfd80976b9dd0120b8fb80b79 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 15:00:44 -0500 Subject: [PATCH 09/12] Hide camera hosts while reparenting to kill the view-switch flash --- SentryReplay/MainWindow.xaml.cs | 97 ++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/SentryReplay/MainWindow.xaml.cs b/SentryReplay/MainWindow.xaml.cs index f400c38..2649ba6 100644 --- a/SentryReplay/MainWindow.xaml.cs +++ b/SentryReplay/MainWindow.xaml.cs @@ -118,50 +118,73 @@ private void UpdateCameraHostLayout() if (PrimaryCameraHostSlot is null) return; - switch (_viewModel.SelectedCameraView) - { - case MainWindowViewModel.GridCameraView: - MoveHostToSlot(FrontFlyleafHost, GridFrontHostSlot); - MoveHostToSlot(BackFlyleafHost, GridRearHostSlot); - MoveHostToSlot(LeftFlyleafHost, GridLeftHostSlot); - MoveHostToSlot(RightFlyleafHost, GridRightHostSlot); - break; - - case MainWindowViewModel.RearCameraView: - MoveHostToSlot(BackFlyleafHost, PrimaryCameraHostSlot); - MoveHostToSlot(FrontFlyleafHost, FrontTileHostSlot); - MoveHostToSlot(LeftFlyleafHost, LeftTileHostSlot); - MoveHostToSlot(RightFlyleafHost, RightTileHostSlot); - break; + var layout = GetCameraHostLayout(_viewModel.SelectedCameraView); - case MainWindowViewModel.LeftCameraView: - MoveHostToSlot(LeftFlyleafHost, PrimaryCameraHostSlot); - MoveHostToSlot(FrontFlyleafHost, FrontTileHostSlot); - MoveHostToSlot(BackFlyleafHost, RearTileHostSlot); - MoveHostToSlot(RightFlyleafHost, RightTileHostSlot); - break; - - case MainWindowViewModel.RightCameraView: - MoveHostToSlot(RightFlyleafHost, PrimaryCameraHostSlot); - MoveHostToSlot(FrontFlyleafHost, FrontTileHostSlot); - MoveHostToSlot(BackFlyleafHost, RearTileHostSlot); - MoveHostToSlot(LeftFlyleafHost, LeftTileHostSlot); - break; + // Hide every host that's about to move BEFORE reparenting it. A FlyleafHost's native surface is + // hidden while the control isn't visible, so this keeps the surface from briefly painting at its + // default position (a small player flashing in the top-left) while the new slot is laid out. + foreach (var (host, slot) in layout) + { + if (!ReferenceEquals(slot.Content, host)) + { + host.Visibility = Visibility.Hidden; + } + } - default: - MoveHostToSlot(FrontFlyleafHost, PrimaryCameraHostSlot); - MoveHostToSlot(BackFlyleafHost, RearTileHostSlot); - MoveHostToSlot(LeftFlyleafHost, LeftTileHostSlot); - MoveHostToSlot(RightFlyleafHost, RightTileHostSlot); - break; + foreach (var (host, slot) in layout) + { + MoveHostToSlot(host, slot); } - // Force a synchronous layout pass so each moved host gets its final bounds before the next render - // frame. The Flyleaf surface follows the host via its LayoutUpdated event, so without this it would - // briefly render at a stale/default rect (a small player flashing in the top-left) before snapping. + // Lay the moved hosts out (which repositions their hidden surfaces) before showing them again, + // so each surface only ever appears at its final slot bounds. UpdateLayout(); + + foreach (var (host, _) in layout) + { + host.Visibility = Visibility.Visible; + } } + private IReadOnlyList<(FlyleafHost Host, ContentControl Slot)> GetCameraHostLayout(string view) => view switch + { + MainWindowViewModel.GridCameraView => + [ + (FrontFlyleafHost, GridFrontHostSlot), + (BackFlyleafHost, GridRearHostSlot), + (LeftFlyleafHost, GridLeftHostSlot), + (RightFlyleafHost, GridRightHostSlot), + ], + MainWindowViewModel.RearCameraView => + [ + (BackFlyleafHost, PrimaryCameraHostSlot), + (FrontFlyleafHost, FrontTileHostSlot), + (LeftFlyleafHost, LeftTileHostSlot), + (RightFlyleafHost, RightTileHostSlot), + ], + MainWindowViewModel.LeftCameraView => + [ + (LeftFlyleafHost, PrimaryCameraHostSlot), + (FrontFlyleafHost, FrontTileHostSlot), + (BackFlyleafHost, RearTileHostSlot), + (RightFlyleafHost, RightTileHostSlot), + ], + MainWindowViewModel.RightCameraView => + [ + (RightFlyleafHost, PrimaryCameraHostSlot), + (FrontFlyleafHost, FrontTileHostSlot), + (BackFlyleafHost, RearTileHostSlot), + (LeftFlyleafHost, LeftTileHostSlot), + ], + _ => + [ + (FrontFlyleafHost, PrimaryCameraHostSlot), + (BackFlyleafHost, RearTileHostSlot), + (LeftFlyleafHost, LeftTileHostSlot), + (RightFlyleafHost, RightTileHostSlot), + ], + }; + private static void MoveHostToSlot(FlyleafHost host, ContentControl slot) { if (ReferenceEquals(slot.Content, host)) From cc2bd946fc98baabb2e81454ae600532fc2cbccd Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 15:09:23 -0500 Subject: [PATCH 10/12] Remove about/sidebar fades; single-screen icon for selected tile; add refresh button --- SentryReplay/MainWindow.xaml | 107 ++++++++++------------------ SentryReplay/MainWindowViewModel.cs | 32 +++++++-- 2 files changed, 65 insertions(+), 74 deletions(-) diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index 185225b..bb1dad9 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -257,26 +257,9 @@ Margin="4,0" Background="Transparent" BorderThickness="0" - ItemsSource="{Binding FilteredClips, NotifyOnTargetUpdated=True}" + ItemsSource="{Binding FilteredClips}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" SelectedItem="{Binding SelectedClip, Mode=TwoWay}"> - - - - - - - - - - - - - - - diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index e047e27..35d6d6f 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -38,6 +38,10 @@ public partial class MainWindowViewModel : ObservableObject private bool _isSeeking; private bool _isInitialized; + // The source of dashcam roots: auto-discovery by default, or the user's last picked folders. Refresh + // re-evaluates it to rescan for newly added clips (and, for auto-discovery, newly connected drives). + private Func> _rootSource = CamStorage.FindCommonRoots; + /// Creates the playback controller (the view supplies one bound to its Flyleaf hosts). /// Maps a dashcam root to its clips. Defaults to scanning the filesystem; overridable for tests. /// Yields to the UI before a clip loads so the window stays responsive. Overridable for tests. @@ -222,6 +226,7 @@ public string PositionText // True while the clip list is being (re)scanned from disk; drives the sidebar loading indicator. [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RefreshClipsCommand))] private bool _isLoadingClips; [ObservableProperty] @@ -281,7 +286,7 @@ public async Task InitializeAsync() if (_flyleafRuntime.TryStart()) { InitializePlayer(); - await LoadClipsAsync(CamStorage.FindCommonRoots()); + await LoadClipsAsync(_rootSource()); } else { @@ -433,17 +438,19 @@ private async Task OpenFolderAsync() if (dialog.ShowDialog() == true) { + var folders = dialog.FolderNames; Log.Information( "User selected dashcam folders. FolderCount={FolderCount}; Folders={Folders}", - dialog.FolderNames.Length, - dialog.FolderNames); + folders.Length, + folders); if (_playerController is not null) { await _playerController.StopAsync(); } - await LoadClipsAsync(dialog.FolderNames); + _rootSource = () => folders; + await LoadClipsAsync(folders); } else { @@ -451,6 +458,21 @@ private async Task OpenFolderAsync() } } + [RelayCommand(CanExecute = nameof(CanRefreshClips))] + private async Task RefreshClipsAsync() + { + Log.Debug("Refreshing clips"); + + if (_playerController is not null) + { + await _playerController.StopAsync(); + } + + await LoadClipsAsync(_rootSource()); + } + + private bool CanRefreshClips => !IsLoadingClips; + [RelayCommand] private async Task PlayPauseAsync() { @@ -503,7 +525,7 @@ private async Task DownloadFFmpegAsync() if (_flyleafRuntime.TryStart()) { InitializePlayer(); - await LoadClipsAsync(CamStorage.FindCommonRoots()); + await LoadClipsAsync(_rootSource()); } else { From 84e3fecc1a1226b0668e383098607e3f5cb79a3a Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 15:15:28 -0500 Subject: [PATCH 11/12] Simplify camera layout (drop ineffective hide-during-reparent) --- SentryReplay/MainWindow.xaml.cs | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/SentryReplay/MainWindow.xaml.cs b/SentryReplay/MainWindow.xaml.cs index 2649ba6..524444e 100644 --- a/SentryReplay/MainWindow.xaml.cs +++ b/SentryReplay/MainWindow.xaml.cs @@ -118,32 +118,15 @@ private void UpdateCameraHostLayout() if (PrimaryCameraHostSlot is null) return; - var layout = GetCameraHostLayout(_viewModel.SelectedCameraView); - - // Hide every host that's about to move BEFORE reparenting it. A FlyleafHost's native surface is - // hidden while the control isn't visible, so this keeps the surface from briefly painting at its - // default position (a small player flashing in the top-left) while the new slot is laid out. - foreach (var (host, slot) in layout) - { - if (!ReferenceEquals(slot.Content, host)) - { - host.Visibility = Visibility.Hidden; - } - } - - foreach (var (host, slot) in layout) + foreach (var (host, slot) in GetCameraHostLayout(_viewModel.SelectedCameraView)) { MoveHostToSlot(host, slot); } - // Lay the moved hosts out (which repositions their hidden surfaces) before showing them again, - // so each surface only ever appears at its final slot bounds. + // Force a synchronous layout pass so each moved host reaches its final bounds promptly. Note: a + // brief flash of the reparented Flyleaf surface at its old size is a known limitation here and is + // accepted (eliminating it would require not reparenting the hosts at all). UpdateLayout(); - - foreach (var (host, _) in layout) - { - host.Visibility = Visibility.Visible; - } } private IReadOnlyList<(FlyleafHost Host, ContentControl Slot)> GetCameraHostLayout(string view) => view switch From 5d5b8a3bba7bc68541552e7cbd9d6e29e2d9347a Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 18 Jun 2026 15:22:13 -0500 Subject: [PATCH 12/12] Refresh visibly clears the clip list and shows the loading bar before refilling --- SentryReplay/MainWindowViewModel.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs index 35d6d6f..2b8c1d1 100644 --- a/SentryReplay/MainWindowViewModel.cs +++ b/SentryReplay/MainWindowViewModel.cs @@ -316,15 +316,20 @@ public void InitializePlayer() _playerController.PlaybackSpeed = SelectedPlaybackSpeed; } - public async Task LoadClipsAsync(IEnumerable roots) + public async Task LoadClipsAsync(IEnumerable roots, TimeSpan minimumLoadingDuration = default) { ClearError(); _allClips.Clear(); SelectedClip = null; IsLoading = true; IsLoadingClips = true; + + // Clear the list right away so a (re)scan visibly empties it and shows the loading bar before refilling. + OnPropertyChanged(nameof(FilteredClips)); RefreshClipState(); + var stopwatch = Stopwatch.StartNew(); + try { // Scan the disk off the UI thread; the continuation resumes on it via the WPF @@ -348,6 +353,14 @@ public async Task LoadClipsAsync(IEnumerable roots) } _playerController?.LoadClips(_allClips); + + // Hold the loading state briefly so a fast rescan still reads as a deliberate refresh + // (clear -> loading -> refill) instead of an imperceptible flicker. + var remaining = minimumLoadingDuration - stopwatch.Elapsed; + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining); + } } finally { @@ -468,7 +481,7 @@ private async Task RefreshClipsAsync() await _playerController.StopAsync(); } - await LoadClipsAsync(_rootSource()); + await LoadClipsAsync(_rootSource(), TimeSpan.FromMilliseconds(400)); } private bool CanRefreshClips => !IsLoadingClips;