From d3a1cc80fb6c80e97ee554e5b40e18af03edcf84 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 24 Sep 2026 07:23:11 -0500 Subject: [PATCH 1/8] Remove the unused render-progress state IsRendering and RenderProgress were never set outside a test, so the loading overlay always read "Loading..." with an indeterminate bar. The overlay now says so directly, which keeps the dead state from being carried into the view-model split. --- SentryDeck.Tests/MainWindowViewModelTests.cs | 19 ------------------- SentryDeck/MainWindow.xaml | 6 ++---- SentryDeck/MainWindowViewModel.cs | 20 -------------------- 3 files changed, 2 insertions(+), 43 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 30bd128..666ee68 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -388,7 +388,6 @@ public void Loading_ShowsStatusOverlay_AndHidesVideo() vm.ShowStatusOverlay.ShouldBeTrue(); vm.ShowVideoHosts.ShouldBeFalse(); - vm.IsIndeterminateProgress.ShouldBeTrue(); } [Fact] @@ -461,24 +460,6 @@ public void PlayPauseIcon_ReflectsPlaybackState(bool isPlaying, string expectedI vm.PlayPauseIcon.ShouldBe(expectedIcon); } - [Fact] - public void LoadingStatusText_ShowsRenderProgressWhileRendering() - { - var vm = CreateViewModel(); - vm.IsLoading = true; - - vm.LoadingStatusText.ShouldBe("Loading..."); - vm.IsIndeterminateProgress.ShouldBeTrue(); - - vm.IsRendering = true; - vm.RenderProgress = 0.5; - - vm.RenderProgressPercent.ShouldBe(50); - vm.LoadingStatusText.ShouldBe("Rendering... 50%"); - // A determinate render progress bar replaces the indeterminate spinner. - vm.IsIndeterminateProgress.ShouldBeFalse(); - } - [Fact] public void UpdateBadge_DefaultsToUpToDate() { diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index d36c960..e59da54 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -1236,16 +1236,14 @@ - + IsIndeterminate="True" /> diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index 2195a39..eaf1637 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -233,8 +233,6 @@ public string PositionText public bool HasNoClipSelected => SelectedClip is null && !IsLoading && !ShowErrorOverlay; - public bool IsIndeterminateProgress => IsLoading && !IsRendering; - public bool IsGridViewSelected => SelectedCameraView == GridCameraView; public bool IsSingleCameraViewSelected => !IsGridViewSelected; @@ -256,12 +254,6 @@ public string PositionText _ => CameraNames.DisplayName(camera), }; - public string LoadingStatusText => IsRendering - ? $"Rendering... {RenderProgressPercent}%" - : "Loading..."; - - public int RenderProgressPercent => (int)(RenderProgress * 100); - // --- Seek-bar overlays for the selected clip (event moment + chunk seams + gaps) --- // Recomputed whenever the selection changes or the controller opens/replaces its media source; plain fields (not ObservableProperty) because they're derived, not independently settable. private double? _eventPosition; @@ -412,8 +404,6 @@ public string SelectionDurationText [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanPlayPause))] [NotifyPropertyChangedFor(nameof(CanStop))] - [NotifyPropertyChangedFor(nameof(LoadingStatusText))] - [NotifyPropertyChangedFor(nameof(IsIndeterminateProgress))] [NotifyPropertyChangedFor(nameof(ShowStatusOverlay))] [NotifyPropertyChangedFor(nameof(ShowVideoHosts))] [NotifyPropertyChangedFor(nameof(HasNoClipSelected))] @@ -427,22 +417,12 @@ public string SelectionDurationText [NotifyCanExecuteChangedFor(nameof(ExportSelectionCommand))] private bool _isLoading; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(LoadingStatusText))] - [NotifyPropertyChangedFor(nameof(IsIndeterminateProgress))] - private bool _isRendering; - // True while the clip list is being (re)scanned from disk; drives the sidebar loading indicator. [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RefreshClipsCommand))] [NotifyCanExecuteChangedFor(nameof(OpenFolderCommand))] private bool _isLoadingClips; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(RenderProgressPercent))] - [NotifyPropertyChangedFor(nameof(LoadingStatusText))] - private double _renderProgress; - [ObservableProperty] [NotifyPropertyChangedFor(nameof(PositionText))] private double _seekPosition; From 0f25e99a33261c20c020b553194898fdfe8f38e5 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Thu, 24 Sep 2026 07:30:04 -0500 Subject: [PATCH 2/8] Move the error overlay into its own view-model The first step of splitting MainWindowViewModel by feature. ErrorOverlayViewModel owns the notice that covers the video area (title, details, dismissability, the first-run empty state, and the FFmpeg download prompt), and every feature reports through the one instance the main window owns. The overlay-visibility logic that combines errors with loading and selection state stays on the main view-model. --- .../MainWindowViewModelTests.Clips.cs | 30 ++--- .../MainWindowViewModelTests.Playback.cs | 8 +- .../MainWindowViewModelTests.Trimming.cs | 12 +- SentryDeck.Tests/MainWindowViewModelTests.cs | 28 ++--- SentryDeck/MainWindow.xaml | 20 ++-- SentryDeck/MainWindowViewModel.cs | 104 ++++++------------ .../ViewModels/ErrorOverlayViewModel.cs | 52 +++++++++ 7 files changed, 137 insertions(+), 117 deletions(-) create mode 100644 SentryDeck/ViewModels/ErrorOverlayViewModel.cs diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 98e9a63..e8df1b2 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -148,10 +148,10 @@ public async Task LoadClips_WithNoRoots_ShowsDismissibleEmptyState() await vm.LoadClipsAsync([]); // First run with no USB drive attached: a friendly prompt the user can dismiss to reach the rest of the app, not a scary error they're stuck behind. - vm.ErrorTitle.ShouldBe("No dashcam footage yet"); - vm.IsEmptyState.ShouldBeTrue(); - vm.CanDismissError.ShouldBeTrue(); - vm.ShowErrorOverlay.ShouldBeTrue(); + vm.Error.Title.ShouldBe("No dashcam footage yet"); + vm.Error.IsEmptyState.ShouldBeTrue(); + vm.Error.CanDismiss.ShouldBeTrue(); + vm.Error.IsVisible.ShouldBeTrue(); vm.ShowStatusOverlay.ShouldBeTrue(); vm.ClipCount.ShouldBe(0); } @@ -164,10 +164,10 @@ public async Task LoadClips_AccessDenied_ShowsAccessDeniedError() await vm.LoadClipsAsync([@"D:\TeslaCam"]); // A permissions problem gets its own title and remedy; it isn't the empty state. - vm.ErrorTitle.ShouldBe("Access Denied"); - vm.ErrorDetails.ShouldContain(@"D:\TeslaCam"); - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.IsEmptyState.ShouldBeFalse(); + vm.Error.Title.ShouldBe("Access Denied"); + vm.Error.Details.ShouldContain(@"D:\TeslaCam"); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.IsEmptyState.ShouldBeFalse(); } [Fact] @@ -178,9 +178,9 @@ public async Task LoadClips_LoaderThrows_ShowsGenericLoadError() await vm.LoadClipsAsync([@"E:\TeslaCam"]); // Both halves matter for a bug report: which folder failed, and what the failure was. - vm.ErrorTitle.ShouldBe("Error Loading Clips"); - vm.ErrorDetails.ShouldContain(@"E:\TeslaCam"); - vm.ErrorDetails.ShouldContain("the drive was removed"); + vm.Error.Title.ShouldBe("Error Loading Clips"); + vm.Error.Details.ShouldContain(@"E:\TeslaCam"); + vm.Error.Details.ShouldContain("the drive was removed"); } [Fact] @@ -203,8 +203,8 @@ public async Task LoadClips_OneRootFails_KeepsClipsFromTheHealthyRoot() // Scanning is per-root: one unreadable drive reports itself but must not cost the user the library on the drive that is still plugged in. vm.ClipCount.ShouldBe(2); - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.ErrorTitle.ShouldBe("Error Loading Clips"); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Error Loading Clips"); } // --- Delete to Recycle Bin: the injectable confirm/recycle delegates keep this off the shell --- @@ -318,8 +318,8 @@ public async Task DeleteClip_WhenRecycleFails_ShowsError_AndKeepsClip() var target = vm.FilteredClips[0]; await vm.DeleteClipCommand.ExecuteAsync(target); - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.ErrorTitle.ShouldBe("Delete Failed"); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Delete Failed"); vm.ClipCount.ShouldBe(2); vm.FilteredClips.ShouldContain(target); } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 6341f12..613c337 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -158,9 +158,9 @@ public void ControllerError_ShowsErrorOverlay() controller.ErrorMessage = "decode failed"; - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.ErrorTitle.ShouldBe("Playback Error"); - vm.ErrorDetails.ShouldBe("decode failed"); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Playback Error"); + vm.Error.Details.ShouldBe("decode failed"); } [Fact] @@ -186,7 +186,7 @@ public void SelectingClip_NotYetOpened_ShowsLoadingWithoutAnError() // The clip is intentionally NOT in the controller's playlist, so GoToClipAsync is a deterministic no-op; this verifies only that selection triggers the auto-play loading state. // Opening media is VideoPlayerController's own job. vm.IsLoading.ShouldBeTrue(); - vm.ShowErrorOverlay.ShouldBeFalse(); + vm.Error.IsVisible.ShouldBeFalse(); } [Fact] diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs index 59e1f55..b720c43 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs @@ -248,7 +248,7 @@ public void ExportSelection_SaveDialogCanceled_DoesNotExport() RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); exporter.Requests.ShouldBeEmpty(); - vm.ShowErrorOverlay.ShouldBeFalse(); + vm.Error.IsVisible.ShouldBeFalse(); } [Fact] @@ -265,9 +265,9 @@ public void ExportSelection_ExporterFailure_ShowsErrorAndResetsBusyState() RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.ErrorTitle.ShouldBe("Export Failed"); - vm.ErrorDetails.ShouldContain("ffmpeg exploded"); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Export Failed"); + vm.Error.Details.ShouldContain("ffmpeg exploded"); vm.IsExporting.ShouldBeFalse(); } @@ -336,8 +336,8 @@ public async Task SaveEventClip_BuilderThrows_ShowsErrorInsteadOfCrashing() await vm.SaveEventClipCommand.ExecuteAsync(clip); - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.ErrorTitle.ShouldBe("Export Failed"); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Export Failed"); exporter.Requests.ShouldBeEmpty(); vm.IsExporting.ShouldBeFalse(); } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 666ee68..4197da3 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -395,7 +395,7 @@ public void Error_ShowsStatusOverlay_AndReportsError() { var vm = CreateViewModel(); - vm.ShowErrorOverlay = true; + vm.Error.IsVisible = true; vm.HasError.ShouldBeTrue(); vm.ShowStatusOverlay.ShouldBeTrue(); @@ -541,18 +541,18 @@ public async Task UnhandledKeys_WithoutPlayer_ReturnFalse(Key key, ModifierKeys public void DismissError_ClearsErrorState() { var vm = CreateViewModel(); - vm.ShowErrorOverlay = true; - vm.ShowFFmpegDownloadButton = true; - vm.CanDismissError = false; - vm.ErrorTitle = "Boom"; - vm.ErrorDetails = "Something went wrong"; - - vm.DismissErrorCommand.Execute(null); - - vm.ShowErrorOverlay.ShouldBeFalse(); - vm.ShowFFmpegDownloadButton.ShouldBeFalse(); - vm.CanDismissError.ShouldBeTrue(); - vm.ErrorTitle.ShouldBeNull(); - vm.ErrorDetails.ShouldBeNull(); + vm.Error.IsVisible = true; + vm.Error.ShowFFmpegDownloadButton = true; + vm.Error.CanDismiss = false; + vm.Error.Title = "Boom"; + vm.Error.Details = "Something went wrong"; + + vm.Error.DismissCommand.Execute(null); + + vm.Error.IsVisible.ShouldBeFalse(); + vm.Error.ShowFFmpegDownloadButton.ShouldBeFalse(); + vm.Error.CanDismiss.ShouldBeTrue(); + vm.Error.Title.ShouldBeNull(); + vm.Error.Details.ShouldBeNull(); } } diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index e59da54..3e203fc 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -1247,7 +1247,7 @@ - + - @@ -1271,7 +1271,7 @@ - @@ -1281,7 +1281,7 @@ - @@ -1290,7 +1290,7 @@ BasedOn="{StaticResource AppSubtitle}"> - @@ -1298,7 +1298,7 @@ - + Visibility="{Binding Error.IsEmptyState, Converter={local:BoolToVisibilityConverter}}" />