diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 98e9a63..d0ee1cd 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -14,9 +14,9 @@ public async Task FilteredClips_OrderNewestFirst() var clips = TestClips.Create(3); // timestamps increase with index var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); - vm.FilteredClips.Select(c => c.Name).ShouldBe(new[] { "Clip 2", "Clip 1", "Clip 0" }); + vm.Library.FilteredClips.Select(c => c.Name).ShouldBe(new[] { "Clip 2", "Clip 1", "Clip 0" }); } [Fact] @@ -24,11 +24,11 @@ public async Task FilteredClips_FiltersByNameCaseInsensitively() { var clips = TestClips.Create(3); var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); - vm.FilterText = "clip 1"; + vm.Library.FilterText = "clip 1"; - vm.FilteredClips.Single().Name.ShouldBe("Clip 1"); + vm.Library.FilteredClips.Single().Name.ShouldBe("Clip 1"); } [Fact] @@ -37,11 +37,11 @@ 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); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); - vm.FilterText = clips[0].FullPath; + vm.Library.FilterText = clips[0].FullPath; - vm.FilteredClips.Count.ShouldBe(2); + vm.Library.FilteredClips.Count.ShouldBe(2); } [Fact] @@ -53,11 +53,11 @@ public async Task FilteredClips_FiltersByCity() ClipWithEvent("B", "user_interaction_honk", "San Antonio"), }; var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); - vm.FilterText = "hutto"; + vm.Library.FilterText = "hutto"; - vm.FilteredClips.Single().Name.ShouldBe("A"); + vm.Library.FilteredClips.Single().Name.ShouldBe("A"); } [Fact] @@ -69,11 +69,11 @@ public async Task FilteredClips_FiltersByFriendlyReason() ClipWithEvent("Saver", "user_interaction_dashcam_launcher_action_tapped", "X"), }; var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); - vm.FilterText = "saved"; + vm.Library.FilterText = "saved"; - vm.FilteredClips.Single().Name.ShouldBe("Saver"); + vm.Library.FilteredClips.Single().Name.ShouldBe("Saver"); } [Fact] @@ -81,26 +81,26 @@ public async Task ClipCount_ReflectsFilteredCount() { var clips = TestClips.Create(3); var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); - vm.ClipCount.ShouldBe(3); + vm.Library.ClipCount.ShouldBe(3); - vm.FilterText = "Clip 1"; + vm.Library.FilterText = "Clip 1"; - vm.ClipCount.ShouldBe(1); + vm.Library.ClipCount.ShouldBe(1); } [Fact] public void ClearFilter_ResetsFilterTextAndFlag() { var vm = CreateViewModel(); - vm.FilterText = "abc"; - vm.HasFilterText.ShouldBeTrue(); + vm.Library.FilterText = "abc"; + vm.Library.HasFilterText.ShouldBeTrue(); - vm.ClearFilterCommand.Execute(null); + vm.Library.ClearFilterCommand.Execute(null); - vm.FilterText.ShouldBe(string.Empty); - vm.HasFilterText.ShouldBeFalse(); + vm.Library.FilterText.ShouldBe(string.Empty); + vm.Library.HasFilterText.ShouldBeFalse(); } [Fact] @@ -108,22 +108,22 @@ public async Task TypingInSearch_DoesNotRebindTheListPerKeystroke() { var clips = TestClips.Create(3); var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(["root"]); + await vm.Library.LoadClipsAsync(["root"]); var changed = new List(); - vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + vm.Library.PropertyChanged += (_, e) => changed.Add(e.PropertyName); - vm.FilterText = "C"; - vm.FilterText = "Cl"; - vm.FilterText = "Cli"; + vm.Library.FilterText = "C"; + vm.Library.FilterText = "Cl"; + vm.Library.FilterText = "Cli"; // The list rebind is deferred to a debounce timer (which never ticks in tests) so the ListBox doesn't rebuild and replay its fade on every keystroke. // Wiring FilteredClips/ClipCount straight onto FilterText would look harmless and quietly undo that. - changed.ShouldNotContain(nameof(MainWindowViewModel.FilteredClips)); - changed.ShouldNotContain(nameof(MainWindowViewModel.ClipCount)); + changed.ShouldNotContain(nameof(ClipLibraryViewModel.FilteredClips)); + changed.ShouldNotContain(nameof(ClipLibraryViewModel.ClipCount)); // The clear affordance is the one part that stays immediate. - changed.ShouldContain(nameof(MainWindowViewModel.HasFilterText)); + changed.ShouldContain(nameof(ClipLibraryViewModel.HasFilterText)); } [Fact] @@ -133,8 +133,8 @@ public void ShowOnMap_DisabledWithoutCoordinates() var noLocation = ClipWithEvent("A", "user_interaction_honk", "Hutto"); var withLocation = ClipWithEvent("B", "user_interaction_honk", "Hutto", 30.5m, -97.5m); - vm.ShowOnMapCommand.CanExecute(noLocation).ShouldBeFalse(); - vm.ShowOnMapCommand.CanExecute(withLocation).ShouldBeTrue(); + vm.Library.ShowOnMapCommand.CanExecute(noLocation).ShouldBeFalse(); + vm.Library.ShowOnMapCommand.CanExecute(withLocation).ShouldBeTrue(); } // --- Scanning: what the sidebar and the overlay show when there is nothing to scan, or a root can't be read. @@ -145,15 +145,15 @@ public async Task LoadClips_WithNoRoots_ShowsDismissibleEmptyState() { var vm = CreateViewModel(); - await vm.LoadClipsAsync([]); + await vm.Library.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); + vm.Library.ClipCount.ShouldBe(0); } [Fact] @@ -161,13 +161,13 @@ public async Task LoadClips_AccessDenied_ShowsAccessDeniedError() { var vm = new MainWindowViewModel(() => null!, clipLoader: _ => throw new UnauthorizedAccessException("denied")); - await vm.LoadClipsAsync([@"D:\TeslaCam"]); + await vm.Library.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] @@ -175,12 +175,12 @@ public async Task LoadClips_LoaderThrows_ShowsGenericLoadError() { var vm = new MainWindowViewModel(() => null!, clipLoader: _ => throw new IOException("the drive was removed")); - await vm.LoadClipsAsync([@"E:\TeslaCam"]); + await vm.Library.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] @@ -199,12 +199,12 @@ public async Task LoadClips_OneRootFails_KeepsClipsFromTheHealthyRoot() return clips; }); - await vm.LoadClipsAsync(["bad", "good"]); + await vm.Library.LoadClipsAsync(["bad", "good"]); // 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.Library.ClipCount.ShouldBe(2); + 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 --- @@ -222,7 +222,7 @@ private static List ClipsWithDistinctPaths(int count) => private static async Task LoadedViewModelAsync(IReadOnlyList clips) { var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); return vm; } @@ -231,8 +231,8 @@ public void DeleteClipCommand_CanExecute_RequiresAClip() { var vm = CreateViewModel(); - vm.DeleteClipCommand.CanExecute(null).ShouldBeFalse(); - vm.DeleteClipCommand.CanExecute(TestClips.Create(1)[0]).ShouldBeTrue(); + vm.Library.DeleteClipCommand.CanExecute(null).ShouldBeFalse(); + vm.Library.DeleteClipCommand.CanExecute(TestClips.Create(1)[0]).ShouldBeTrue(); } [Fact] @@ -241,15 +241,15 @@ public async Task DeleteClip_Confirmed_RecyclesFolder_AndRemovesFromList() var clips = ClipsWithDistinctPaths(3); var vm = await LoadedViewModelAsync(clips); string recycledPath = null; - vm.ConfirmDeleteClip = _ => true; - vm.RecycleClipFolder = path => recycledPath = path; + vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.RecycleClipFolder = path => recycledPath = path; - var target = vm.FilteredClips.Single(clip => clip.Name == "Clip 1"); - await vm.DeleteClipCommand.ExecuteAsync(target); + var target = vm.Library.FilteredClips.Single(clip => clip.Name == "Clip 1"); + await vm.Library.DeleteClipCommand.ExecuteAsync(target); recycledPath.ShouldBe(target.FullPath); - vm.FilteredClips.ShouldNotContain(target); - vm.ClipCount.ShouldBe(2); + vm.Library.FilteredClips.ShouldNotContain(target); + vm.Library.ClipCount.ShouldBe(2); } [Fact] @@ -258,15 +258,15 @@ public async Task DeleteClip_Cancelled_KeepsClip_AndDoesNotRecycle() var clips = ClipsWithDistinctPaths(2); var vm = await LoadedViewModelAsync(clips); var recycleCalls = 0; - vm.ConfirmDeleteClip = _ => false; - vm.RecycleClipFolder = _ => recycleCalls++; + vm.Library.ConfirmDeleteClip = _ => false; + vm.Library.RecycleClipFolder = _ => recycleCalls++; - var target = vm.FilteredClips[0]; - await vm.DeleteClipCommand.ExecuteAsync(target); + var target = vm.Library.FilteredClips[0]; + await vm.Library.DeleteClipCommand.ExecuteAsync(target); recycleCalls.ShouldBe(0); - vm.ClipCount.ShouldBe(2); - vm.FilteredClips.ShouldContain(target); + vm.Library.ClipCount.ShouldBe(2); + vm.Library.FilteredClips.ShouldContain(target); } [Fact] @@ -274,18 +274,18 @@ public async Task DeleteClip_TheSelectedClip_ClearsSelectionAndNowPlaying() { var clips = ClipsWithDistinctPaths(2); var vm = await LoadedViewModelAsync(clips); - vm.ConfirmDeleteClip = _ => true; - vm.RecycleClipFolder = _ => { }; + vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.RecycleClipFolder = _ => { }; - var target = vm.FilteredClips[0]; - vm.SelectedClip = target; // sets NowPlayingClip too (see OnSelectedClipChanged) - vm.NowPlayingClip.ShouldBe(target); + var target = vm.Library.FilteredClips[0]; + vm.Library.SelectedClip = target; // sets NowPlayingClip too (see OnSelectedClipChanged) + vm.Playback.NowPlayingClip.ShouldBe(target); - await vm.DeleteClipCommand.ExecuteAsync(target); + await vm.Library.DeleteClipCommand.ExecuteAsync(target); - vm.SelectedClip.ShouldBeNull(); - vm.NowPlayingClip.ShouldBeNull(); - vm.FilteredClips.ShouldNotContain(target); + vm.Library.SelectedClip.ShouldBeNull(); + vm.Playback.NowPlayingClip.ShouldBeNull(); + vm.Library.FilteredClips.ShouldNotContain(target); } [Fact] @@ -293,18 +293,18 @@ public async Task DeleteClip_NotTheSelectedClip_LeavesSelectionIntact() { var clips = ClipsWithDistinctPaths(3); var vm = await LoadedViewModelAsync(clips); - vm.ConfirmDeleteClip = _ => true; - vm.RecycleClipFolder = _ => { }; + vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.RecycleClipFolder = _ => { }; - var selected = vm.FilteredClips.Single(clip => clip.Name == "Clip 2"); - var victim = vm.FilteredClips.Single(clip => clip.Name == "Clip 0"); - vm.SelectedClip = selected; + var selected = vm.Library.FilteredClips.Single(clip => clip.Name == "Clip 2"); + var victim = vm.Library.FilteredClips.Single(clip => clip.Name == "Clip 0"); + vm.Library.SelectedClip = selected; - await vm.DeleteClipCommand.ExecuteAsync(victim); + await vm.Library.DeleteClipCommand.ExecuteAsync(victim); - vm.SelectedClip.ShouldBe(selected); - vm.FilteredClips.ShouldNotContain(victim); - vm.ClipCount.ShouldBe(2); + vm.Library.SelectedClip.ShouldBe(selected); + vm.Library.FilteredClips.ShouldNotContain(victim); + vm.Library.ClipCount.ShouldBe(2); } [Fact] @@ -312,16 +312,16 @@ public async Task DeleteClip_WhenRecycleFails_ShowsError_AndKeepsClip() { var clips = ClipsWithDistinctPaths(2); var vm = await LoadedViewModelAsync(clips); - vm.ConfirmDeleteClip = _ => true; - vm.RecycleClipFolder = _ => throw new IOException("The file is in use."); + vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.RecycleClipFolder = _ => throw new IOException("The file is in use."); - var target = vm.FilteredClips[0]; - await vm.DeleteClipCommand.ExecuteAsync(target); + var target = vm.Library.FilteredClips[0]; + await vm.Library.DeleteClipCommand.ExecuteAsync(target); - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.ErrorTitle.ShouldBe("Delete Failed"); - vm.ClipCount.ShouldBe(2); - vm.FilteredClips.ShouldContain(target); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Delete Failed"); + vm.Library.ClipCount.ShouldBe(2); + vm.Library.FilteredClips.ShouldContain(target); } // --- Deleting the clip that is actually open: the point of the feature, and the only path that touches the player. @@ -334,15 +334,15 @@ public async Task DeleteClip_TheOpenClip_StopsPlaybackBeforeRecycling() var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); var closesBeforeDelete = front.Count("close"); var closesWhenRecycled = -1; - vm.ConfirmDeleteClip = _ => true; - vm.RecycleClipFolder = _ => closesWhenRecycled = front.Count("close"); - vm.SeekPosition = 0.5; + vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.RecycleClipFolder = _ => closesWhenRecycled = front.Count("close"); + vm.Playback.SeekPosition = 0.5; - await vm.DeleteClipCommand.ExecuteAsync(clipFiles.Clip); + await vm.Library.DeleteClipCommand.ExecuteAsync(clipFiles.Clip); // Windows can't recycle a folder whose files are still locked, so playback must already be stopped when the shell operation runs -- not merely by the time delete returns. closesWhenRecycled.ShouldBeGreaterThan(closesBeforeDelete); - vm.SeekPosition.ShouldBe(0); + vm.Playback.SeekPosition.ShouldBe(0); } [Fact] @@ -351,16 +351,16 @@ public async Task DeleteClip_TheOpenClip_RemovesItFromThePlayerPlaylist() using var clipFiles = TestClipFiles.Create(chunkCount: 1); var clip = clipFiles.Clip; var (vm, controller, _) = CreateViewModelWithOpenedClip(clip, uiInvoker: action => action()); - vm.ConfirmDeleteClip = _ => true; - vm.RecycleClipFolder = _ => { }; - vm.SelectedClip = clip; // sets NowPlayingClip too (see OnSelectedClipChanged) + vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.RecycleClipFolder = _ => { }; + vm.Library.SelectedClip = clip; // sets NowPlayingClip too (see OnSelectedClipChanged) - await vm.DeleteClipCommand.ExecuteAsync(clip); + await vm.Library.DeleteClipCommand.ExecuteAsync(clip); // Next/Previous walk the controller's playlist, so a deleted clip left behind in it would navigate straight back to a folder that no longer exists. controller.Playlist.Clips.ShouldNotContain(clip); - vm.NowPlayingClip.ShouldBeNull(); - vm.SelectedClip.ShouldBeNull(); + vm.Playback.NowPlayingClip.ShouldBeNull(); + vm.Library.SelectedClip.ShouldBeNull(); } [Fact] @@ -368,10 +368,39 @@ public async Task FilteredClips_NoMatch_IsEmpty() { var clips = TestClips.Create(3); var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); - await vm.LoadClipsAsync(new[] { "root" }); + await vm.Library.LoadClipsAsync(new[] { "root" }); + + vm.Library.FilterText = "no-such-clip"; + + vm.Library.FilteredClips.ShouldBeEmpty(); + } + + [Fact] + public async Task LoadClipsAsync_WhileTheScanRuns_ShowsTheLoadingOverlay() + { + // Loading is shared by the scan, the FFmpeg download, and clip loading; the overlay has to follow the scan even though playback isn't loading anything. + using var scanGate = new ManualResetEventSlim(); + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => + { + scanGate.Wait(); + return TestClips.Create(2); + }); + var changed = new List(); + vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + var load = vm.Library.LoadClipsAsync(["root"]); + + vm.IsLoading.ShouldBeTrue(); + vm.ShowStatusOverlay.ShouldBeTrue(); + vm.HasNoClipSelected.ShouldBeFalse(); + changed.ShouldContain(nameof(MainWindowViewModel.IsLoading)); + changed.ShouldContain(nameof(MainWindowViewModel.ShowStatusOverlay)); - vm.FilterText = "no-such-clip"; + scanGate.Set(); + await load; - vm.FilteredClips.ShouldBeEmpty(); + vm.IsLoading.ShouldBeFalse(); + vm.HasNoClipSelected.ShouldBeTrue(); + vm.Library.ClipCount.ShouldBe(2); } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs index 482a5ec..4200209 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs @@ -105,12 +105,12 @@ public void StopCommand_ClearsNowPlayingClip() using var clipFiles = TestClipFiles.Create(chunkCount: 1); var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); var closesAfterOpen = front.Count("close"); - vm.SelectedClip = clipFiles.Clip; // sets NowPlayingClip too (see OnSelectedClipChanged) + vm.Library.SelectedClip = clipFiles.Clip; // sets NowPlayingClip too (see OnSelectedClipChanged) - RunPinnedToTestThread(() => vm.StopCommand.ExecuteAsync(null)); + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); // Stop is the only thing that takes the now-playing badge off the clip list; leaving it set would mark a clip as playing with nothing loaded. - vm.NowPlayingClip.ShouldBeNull(); + vm.Playback.NowPlayingClip.ShouldBeNull(); front.Count("close").ShouldBeGreaterThan(closesAfterOpen); } @@ -127,12 +127,12 @@ public async Task DeselectingWhileSelectionLoadIsYielding_ClearsTheLoadingOverla backgroundYield: () => yieldGate.Task); vm.InitializePlayer(); - vm.SelectedClip = ClipWithChunks(1); + vm.Library.SelectedClip = ClipWithChunks(1); vm.IsLoading.ShouldBeTrue(); // Clear the selection before the yield resumes (Ctrl+click deselect, or a search filter dropping the clip). // The superseded load must not leave IsLoading stuck true forever. - vm.SelectedClip = null; + vm.Library.SelectedClip = null; yieldGate.SetResult(); await Wait.UntilAsync(() => !vm.IsLoading); @@ -153,17 +153,17 @@ public async Task SupersededSelection_DoesNotClearTheNewerLoadsLoadingState() var superseded = ClipWithCameras(SixCameras); var winner = ClipWithCamerasAndEventCamera(eventCamera: 7, SixCameras); - vm.SelectedClip = superseded; - vm.SelectedClip = winner; + vm.Library.SelectedClip = superseded; + vm.Library.SelectedClip = winner; yieldGate.SetResult(); // The winner's load resumes and auto-focuses the rear camera. // The superseded load is dropped on its way out, and the loading state it finds is no longer its own to clear -- doing so would strand the newer clip's open with no progress indication at all. - await Wait.UntilAsync(() => vm.SelectedCameraView == CameraNames.Back); + await Wait.UntilAsync(() => vm.Cameras.SelectedCameraView == CameraNames.Back); vm.IsLoading.ShouldBeTrue(); - vm.NowPlayingClip.ShouldBe(winner); - vm.SelectedClip.ShouldBe(winner); + vm.Playback.NowPlayingClip.ShouldBe(winner); + vm.Library.SelectedClip.ShouldBe(winner); } [Fact] @@ -172,12 +172,12 @@ public void OpenFolderAndRefresh_AreDisabledWhileClipsAreScanning() // Both commands funnel into LoadClipsAsync, which has no re-entrancy protection: a second load started mid-scan would interleave with the first and merge both roots' clips. var vm = CreateViewModel(); - vm.OpenFolderCommand.CanExecute(null).ShouldBeTrue(); - vm.RefreshClipsCommand.CanExecute(null).ShouldBeTrue(); + vm.Library.OpenFolderCommand.CanExecute(null).ShouldBeTrue(); + vm.Library.RefreshClipsCommand.CanExecute(null).ShouldBeTrue(); - vm.IsLoadingClips = true; + vm.Library.IsLoadingClips = true; - vm.OpenFolderCommand.CanExecute(null).ShouldBeFalse(); - vm.RefreshClipsCommand.CanExecute(null).ShouldBeFalse(); + vm.Library.OpenFolderCommand.CanExecute(null).ShouldBeFalse(); + vm.Library.RefreshClipsCommand.CanExecute(null).ShouldBeFalse(); } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs index 3c13c52..6e96087 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs @@ -14,11 +14,11 @@ public void EventMarker_NearClipEnd_MapsToHighFraction() var vm = CreateViewModel(); // 10 one-minute chunks (600s modeled); event at 9m30s in -> 0.95. - vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); + vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); - vm.HasEventMarker.ShouldBeTrue(); - vm.EventMarkerPosition.ShouldBe(0.95, 0.0001); - vm.EventMarkerTooltip.ShouldStartWith("Honk · "); + vm.Playback.HasEventMarker.ShouldBeTrue(); + vm.Playback.EventMarkerPosition.ShouldBe(0.95, 0.0001); + vm.Playback.EventMarkerTooltip.ShouldStartWith("Honk · "); } [Fact] @@ -26,11 +26,11 @@ public void EventMarker_AbsentWithoutEvent() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunks(3); + vm.Library.SelectedClip = ClipWithChunks(3); - vm.HasEventMarker.ShouldBeFalse(); - vm.EventMarkerPosition.ShouldBe(0); - vm.EventMarkerTooltip.ShouldBeEmpty(); + vm.Playback.HasEventMarker.ShouldBeFalse(); + vm.Playback.EventMarkerPosition.ShouldBe(0); + vm.Playback.EventMarkerTooltip.ShouldBeEmpty(); } [Fact] @@ -40,9 +40,9 @@ public void EventMarker_AbsentWhenEventTimestampIsDefault() var chunks = ClipWithChunks(3).Chunks; var camEvent = new CamEvent { Reason = "user_interaction_honk" }; // Timestamp == default - vm.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "Default TS", new DateTime(2025, 1, 1, 12, 0, 0), chunks, camEvent); + vm.Library.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "Default TS", new DateTime(2025, 1, 1, 12, 0, 0), chunks, camEvent); - vm.HasEventMarker.ShouldBeFalse(); + vm.Playback.HasEventMarker.ShouldBeFalse(); } [Fact] @@ -51,10 +51,10 @@ public void EventMarker_AtTheVeryStart_ShowsAtFractionZero() var vm = CreateViewModel(); // Event fired on the first recorded frame (timestamp == first chunk's timestamp): fraction is exactly 0, which is a real position, not clock skew. - vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.Zero); + vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.Zero); - vm.HasEventMarker.ShouldBeTrue(); - vm.EventMarkerPosition.ShouldBe(0); + vm.Playback.HasEventMarker.ShouldBeTrue(); + vm.Playback.EventMarkerPosition.ShouldBe(0); } [Fact] @@ -63,9 +63,9 @@ public void EventMarker_AbsentWhenEventBeforeClipStart() var vm = CreateViewModel(); // Clock skew: event five minutes before the first chunk -> fraction <= 0, no marker. - vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromMinutes(-5)); + vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromMinutes(-5)); - vm.HasEventMarker.ShouldBeFalse(); + vm.Playback.HasEventMarker.ShouldBeFalse(); } [Fact] @@ -74,9 +74,9 @@ public void EventMarker_AbsentWhenEventBeyondModeledDuration() var vm = CreateViewModel(); // 3 chunks = 180s modeled; an event at 200s is past the estimated end (fraction > 1). - vm.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(200)); + vm.Library.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(200)); - vm.HasEventMarker.ShouldBeFalse(); + vm.Playback.HasEventMarker.ShouldBeFalse(); } [Fact] @@ -85,11 +85,11 @@ public void EventMarker_AbsentWhenNoChunks_NoDivideByZero() var vm = CreateViewModel(); var camEvent = new CamEvent { Reason = "user_interaction_honk", Timestamp = new DateTime(2025, 1, 1, 12, 5, 0) }; - vm.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "No Chunks", new DateTime(2025, 1, 1, 12, 0, 0), [], camEvent); + vm.Library.SelectedClip = new CamClip(System.IO.Path.GetTempPath(), "No Chunks", new DateTime(2025, 1, 1, 12, 0, 0), [], camEvent); - vm.HasEventMarker.ShouldBeFalse(); - vm.EventMarkerPosition.ShouldBe(0); - vm.ChunkBoundaries.ShouldBeEmpty(); + vm.Playback.HasEventMarker.ShouldBeFalse(); + vm.Playback.EventMarkerPosition.ShouldBe(0); + vm.Playback.ChunkBoundaries.ShouldBeEmpty(); } [Fact] @@ -97,11 +97,11 @@ public void ChunkBoundaries_AreInteriorFractions() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunks(3); + vm.Library.SelectedClip = ClipWithChunks(3); - vm.ChunkBoundaries.Count.ShouldBe(2); - vm.ChunkBoundaries[0].ShouldBe(1.0 / 3, 0.0001); - vm.ChunkBoundaries[1].ShouldBe(2.0 / 3, 0.0001); + vm.Playback.ChunkBoundaries.Count.ShouldBe(2); + vm.Playback.ChunkBoundaries[0].ShouldBe(1.0 / 3, 0.0001); + vm.Playback.ChunkBoundaries[1].ShouldBe(2.0 / 3, 0.0001); } [Fact] @@ -109,9 +109,9 @@ public void ChunkBoundaries_EmptyForSingleChunk() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunks(1); + vm.Library.SelectedClip = ClipWithChunks(1); - vm.ChunkBoundaries.ShouldBeEmpty(); + vm.Playback.ChunkBoundaries.ShouldBeEmpty(); } // --- Gap-aware markers: once the controller has actually opened the clip's media, event/gap positions come from the real ClipMediaSource (probed durations + wall-clock mapping) rather than the uniform-chunk-length estimate used before the media opens. --- @@ -122,9 +122,9 @@ public void GapPositions_EmptyBeforeMediaOpens() // No controller at all: RecomputeSelectedClipTimeline can only fall back to the estimate, which carries no gap information. var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(90)); + vm.Library.SelectedClip = ClipWithChunksAndEvent(3, TimeSpan.FromSeconds(90)); - vm.GapPositions.ShouldBeEmpty(); + vm.Playback.GapPositions.ShouldBeEmpty(); } [Fact] @@ -137,10 +137,10 @@ public void GapPositions_ReflectAGapOnceMediaSourceIsOpen() var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent: null); var (vm, _, _) = CreateViewModelWithOpenedClip(clip); - vm.SelectedClip = clip; + vm.Library.SelectedClip = clip; // Two included chunks of 60s each = 120s total; the single gap sits at media time 60s. - vm.GapPositions.ShouldBe([60.0 / 120], 0.0001); + vm.Playback.GapPositions.ShouldBe([60.0 / 120], 0.0001); } [Fact] @@ -155,38 +155,38 @@ public void EventMarker_AfterAGap_UsesGapCorrectedFraction_NotLinearTime() var clip = new CamClip(clipFiles.Clip.FullPath, clipFiles.Clip.Name, clipFiles.Clip.Timestamp, chunks, camEvent); var (vm, _, _) = CreateViewModelWithOpenedClip(clip); - vm.SelectedClip = clip; + vm.Library.SelectedClip = clip; - vm.HasEventMarker.ShouldBeTrue(); - vm.EventMarkerPosition.ShouldBe(70.0 / 120, 0.0001); + vm.Playback.HasEventMarker.ShouldBeTrue(); + vm.Playback.EventMarkerPosition.ShouldBe(70.0 / 120, 0.0001); // Sanity check that this genuinely differs from what the naive linear/estimated model (ignoring the gap) would have produced, so the test would fail if gap-awareness regressed. - Math.Abs(vm.EventMarkerPosition - (130.0 / 180)).ShouldBeGreaterThan(0.01); + Math.Abs(vm.Playback.EventMarkerPosition - (130.0 / 180)).ShouldBeGreaterThan(0.01); } [Fact] public void ClearingSelection_ResetsEventMarkerAndChunks() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); - vm.HasEventMarker.ShouldBeTrue(); + vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); + vm.Playback.HasEventMarker.ShouldBeTrue(); - vm.SelectedClip = null; + vm.Library.SelectedClip = null; - vm.HasEventMarker.ShouldBeFalse(); - vm.EventMarkerPosition.ShouldBe(0); - vm.ChunkBoundaries.ShouldBeEmpty(); + vm.Playback.HasEventMarker.ShouldBeFalse(); + vm.Playback.EventMarkerPosition.ShouldBe(0); + vm.Playback.ChunkBoundaries.ShouldBeEmpty(); } [Fact] public void JumpToEvent_CanExecute_FollowsHasEventMarker() { var vm = CreateViewModel(); - vm.JumpToEventCommand.CanExecute(null).ShouldBeFalse(); + vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeFalse(); - vm.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); + vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); - vm.JumpToEventCommand.CanExecute(null).ShouldBeTrue(); + vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeTrue(); } [Fact] @@ -201,7 +201,7 @@ public void EventShortcut_WithAnOpenedEventClip_SeeksThePlayersToTheEventMoment( clipFiles.Clip.Chunks, new CamEvent { Timestamp = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30) }); var (vm, controller, front) = CreateViewModelWithOpenedClip(clip); - vm.SelectedClip = clip; + vm.Library.SelectedClip = clip; var handled = vm.HandleKeyDown(Key.E, ModifierKeys.None); RunPinnedToTestThread(controller.WhenIdleAsync); @@ -209,14 +209,14 @@ public void EventShortcut_WithAnOpenedEventClip_SeeksThePlayersToTheEventMoment( handled.ShouldBeTrue(); front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(90)); controller.Position.ShouldBe(TimeSpan.FromSeconds(90)); - vm.SeekPosition.ShouldBe(0.5, 0.0001); + vm.Playback.SeekPosition.ShouldBe(0.5, 0.0001); } [Fact] public async Task EventShortcut_Ignored_WhenNoMarker() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithChunks(3); // no event + vm.Library.SelectedClip = ClipWithChunks(3); // no event var handled = await vm.HandleKeyDownAsync(Key.E, ModifierKeys.None); @@ -228,12 +228,12 @@ public void SelectingClip_RaisesEventMarkerNotifications() { var vm = CreateViewModel(); var changed = new List(); - vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + vm.Playback.PropertyChanged += (_, e) => changed.Add(e.PropertyName); - vm.SelectedClip = ClipWithChunksAndEvent(5, TimeSpan.FromSeconds(250)); + vm.Library.SelectedClip = ClipWithChunksAndEvent(5, TimeSpan.FromSeconds(250)); - changed.ShouldContain(nameof(MainWindowViewModel.EventMarkerPosition)); - changed.ShouldContain(nameof(MainWindowViewModel.HasEventMarker)); - changed.ShouldContain(nameof(MainWindowViewModel.ChunkBoundaries)); + changed.ShouldContain(nameof(PlaybackViewModel.EventMarkerPosition)); + changed.ShouldContain(nameof(PlaybackViewModel.HasEventMarker)); + changed.ShouldContain(nameof(PlaybackViewModel.ChunkBoundaries)); } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 6341f12..e0dc00b 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -14,24 +14,24 @@ public void SeekMath_PositionTextScalesByDuration() var vm = CreateViewModelWithController(out var controller, out _); controller.Duration = TimeSpan.FromMinutes(2); - vm.SeekPosition = 0.5; + vm.Playback.SeekPosition = 0.5; - vm.PositionText.ShouldBe("1:00"); - vm.DurationText.ShouldBe("2:00"); + vm.Playback.PositionText.ShouldBe("1:00"); + vm.Playback.DurationText.ShouldBe("2:00"); } [Fact] public void CanSeek_RequiresOpenMediaDurationAndNotLoading() { var vm = CreateViewModelWithController(out var controller, out _); - vm.CanSeek.ShouldBeFalse(); // no media open yet + vm.Playback.CanSeek.ShouldBeFalse(); // no media open yet controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.CanSeek.ShouldBeTrue(); + vm.Playback.CanSeek.ShouldBeTrue(); - vm.IsLoading = true; - vm.CanSeek.ShouldBeFalse(); + vm.Playback.IsLoading = true; + vm.Playback.CanSeek.ShouldBeFalse(); } [Fact] @@ -42,7 +42,7 @@ public void ControllerPositionChange_UpdatesSeekPosition() controller.Position = TimeSpan.FromSeconds(30); - vm.SeekPosition.ShouldBe(0.25, 0.0001); + vm.Playback.SeekPosition.ShouldBe(0.25, 0.0001); } [Fact] @@ -52,13 +52,13 @@ public async Task WhileScrubbing_ControllerPositionDoesNotMoveTheSlider() controller.Duration = TimeSpan.FromMinutes(2); controller.IsMediaOpen = true; - vm.BeginSeek(); + vm.Playback.BeginSeek(); controller.Position = TimeSpan.FromSeconds(60); // user is dragging: ignore controller updates - vm.SeekPosition.ShouldBe(0.0); + vm.Playback.SeekPosition.ShouldBe(0.0); - await vm.EndSeekAsync(); + await vm.Playback.EndSeekAsync(); controller.Position = TimeSpan.FromSeconds(30); // updates resume after the drag - vm.SeekPosition.ShouldBe(0.25, 0.0001); + vm.Playback.SeekPosition.ShouldBe(0.25, 0.0001); } // Synchronous (no async/await in the test body itself -- see RunPinnedToTestThread). @@ -68,14 +68,14 @@ public void DragSequence_IssuesFastSeeks_ReleaseIssuesAccurateSeekAtReleasePosit using var clipFiles = TestClipFiles.Create(chunkCount: 1); // 60s clip (see TestClipFiles) var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); - vm.BeginSeek(); + vm.Playback.BeginSeek(); // Simulate a drag: each slider value change while dragging should scrub-seek (fast/keyframe). - vm.SeekPosition = 0.2; // 12s of 60s - vm.OnSeekSliderValueChanged(); + vm.Playback.SeekPosition = 0.2; // 12s of 60s + vm.Playback.OnSeekSliderValueChanged(); - vm.SeekPosition = 0.5; // 30s - vm.OnSeekSliderValueChanged(); + vm.Playback.SeekPosition = 0.5; // 30s + vm.Playback.OnSeekSliderValueChanged(); front.Seeks.ShouldContain((TimeSpan.FromSeconds(12), false)); front.Seeks.ShouldContain((TimeSpan.FromSeconds(30), false)); @@ -84,9 +84,9 @@ public void DragSequence_IssuesFastSeeks_ReleaseIssuesAccurateSeekAtReleasePosit front.Seeks.ShouldAllBe(seek => !seek.Accurate); // Release at 0.75 (45s): EndSeekAsync must issue exactly one ACCURATE seek at the release position. - vm.SeekPosition = 0.75; + vm.Playback.SeekPosition = 0.75; - RunPinnedToTestThread(vm.EndSeekAsync); + RunPinnedToTestThread(vm.Playback.EndSeekAsync); front.Seeks[^1].ShouldBe((TimeSpan.FromSeconds(45), true)); } @@ -98,28 +98,28 @@ public void StaleEndSeek_AfterANewDragStarted_DoesNotUnlockPositionSync() using var clipFiles = TestClipFiles.Create(chunkCount: 1); // 60s clip var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip); - vm.BeginSeek(); - vm.SeekPosition = 0.5; + vm.Playback.BeginSeek(); + vm.Playback.SeekPosition = 0.5; // While gesture #1's accurate release seek is executing, the user grabs the thumb again and starts a new drag. // Gesture #1's completion is then stale: it must NOT clear the active drag's seeking state, or the position sync would yank the thumb mid-drag. front.SeekCallback = () => { front.SeekCallback = null; - vm.BeginSeek(); - vm.SeekPosition = 0.25; + vm.Playback.BeginSeek(); + vm.Playback.SeekPosition = 0.25; }; - RunPinnedToTestThread(vm.EndSeekAsync); + RunPinnedToTestThread(vm.Playback.EndSeekAsync); // A controller position sync arriving during drag #2 must still be ignored. front.RaisePositionChanged(TimeSpan.FromSeconds(50)); - vm.SeekPosition.ShouldBe(0.25); + vm.Playback.SeekPosition.ShouldBe(0.25); // The active gesture still ends normally and re-enables position sync. - RunPinnedToTestThread(vm.EndSeekAsync); + RunPinnedToTestThread(vm.Playback.EndSeekAsync); front.RaisePositionChanged(TimeSpan.FromSeconds(30)); - vm.SeekPosition.ShouldBe(0.5, 0.0001); + vm.Playback.SeekPosition.ShouldBe(0.5, 0.0001); } [Fact] @@ -132,7 +132,7 @@ public void PositionSync_WhenNotDragging_DoesNotTriggerScrubSeeks() // Playback position advances on its own (not a drag): SeekPosition updates via the controller -> UpdateSeekPositionFromController path, which does not go through OnSeekSliderValueChanged, so no scrub seek should ever be issued. controller.Position = TimeSpan.FromSeconds(10); - vm.OnSeekSliderValueChanged(); // the view raises ValueChanged for programmatic changes too + vm.Playback.OnSeekSliderValueChanged(); // the view raises ValueChanged for programmatic changes too front.Seeks.Count.ShouldBe(seeksBefore); } @@ -147,8 +147,8 @@ public void ControllerLoadingAndPlaying_MirrorToViewModel() controller.IsLoading = false; controller.IsPlaying = true; - vm.IsPlaying.ShouldBeTrue(); - vm.PlayPauseIcon.ShouldBe(""); // Pause + vm.Playback.IsPlaying.ShouldBeTrue(); + vm.Playback.PlayPauseIcon.ShouldBe(""); // Pause } [Fact] @@ -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] @@ -170,8 +170,8 @@ public void CanGoNextPrevious_ReflectControllerPlaylist() 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(); - vm.CanGoPrevious.ShouldBeFalse(); + vm.Playback.CanGoNext.ShouldBeTrue(); + vm.Playback.CanGoPrevious.ShouldBeFalse(); } [Fact] @@ -180,13 +180,13 @@ public void SelectingClip_NotYetOpened_ShowsLoadingWithoutAnError() var clip = TestClips.Create(1)[0]; var vm = CreateViewModelWithController(out _, out _); - vm.SelectedClip = clip; + vm.Library.SelectedClip = clip; // Selecting a clip runs OnSelectedClipChanged -> PlaySelectedClipAsync, which sets IsLoading=true (synchronously, before the awaited yield) and calls the controller. // 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] @@ -196,10 +196,10 @@ public void SelectingAnEventClip_AutoFocusesTheTriggeringCamera() // Camera id 7 is the rear camera. // As in SelectingClip_TriggersPlaybackLoading, the clip is deliberately not in the controller's playlist, so GoToClipAsync early-returns and the rest of the selection load runs inline on this thread. - vm.SelectedClip = ClipWithCamerasAndEventCamera(eventCamera: 7, SixCameras); + vm.Library.SelectedClip = ClipWithCamerasAndEventCamera(eventCamera: 7, SixCameras); // Opening an incident on the angle that triggered it is the whole point of the metadata. - vm.SelectedCameraView.ShouldBe(CameraNames.Back); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Back); } [Fact] @@ -214,8 +214,8 @@ public void StopCommand_WhileASelectionIsWaitingToLoad_KeepsItFromPlaying() var vm = new MainWindowViewModel(() => controller, backgroundYield: () => loadGate.Task); vm.InitializePlayer(); - vm.SelectedClip = clipFiles.Clip; - RunPinnedToTestThread(() => vm.StopCommand.ExecuteAsync(null)); + vm.Library.SelectedClip = clipFiles.Clip; + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); loadGate.SetResult(); RunPinnedToTestThread(controller.WhenIdleAsync); @@ -223,4 +223,27 @@ public void StopCommand_WhileASelectionIsWaitingToLoad_KeepsItFromPlaying() controller.IsPlaying.ShouldBeFalse(); vm.IsLoading.ShouldBeFalse(); } + + [Fact] + public void NextCommand_MovesTheListSelectionToTheNextClip() + { + // The player reports clip changes back to the clip list; without that the list would keep highlighting the clip it left. + using var first = TestClipFiles.Create(chunkCount: 1); + using var second = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + var controller = BuildFourCameraController(front); + controller.LoadClips([first.Clip, second.Clip]); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + vm.Library.SelectedClip = first.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + + RunPinnedToTestThread(() => vm.Playback.NextCommand.ExecuteAsync(null)); + RunPinnedToTestThread(controller.WhenIdleAsync); + + vm.Library.SelectedClip.ShouldBe(second.Clip); + vm.Playback.NowPlayingClip.ShouldBe(second.Clip); + controller.CurrentClip.ShouldBe(second.Clip); + front.Count("open").ShouldBe(2); + } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs index 59e1f55..1f54d01 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs @@ -15,19 +15,19 @@ public void MarkSelection_SetsFractions_AndCompletesTheRange() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.SeekPosition = 0.3; - vm.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.3; + vm.Trim.MarkSelectionStartCommand.Execute(null); - vm.HasSelectionStart.ShouldBeTrue(); - vm.SelectionStartPosition.ShouldBe(0.3); - vm.HasSelection.ShouldBeFalse(); // no end yet + vm.Trim.HasSelectionStart.ShouldBeTrue(); + vm.Trim.SelectionStartPosition.ShouldBe(0.3); + vm.Trim.HasSelection.ShouldBeFalse(); // no end yet - vm.SeekPosition = 0.7; - vm.MarkSelectionEndCommand.Execute(null); + vm.Playback.SeekPosition = 0.7; + vm.Trim.MarkSelectionEndCommand.Execute(null); - vm.HasSelection.ShouldBeTrue(); - vm.SelectionEndPosition.ShouldBe(0.7); - vm.CanExportSelection.ShouldBeTrue(); + vm.Trim.HasSelection.ShouldBeTrue(); + vm.Trim.SelectionEndPosition.ShouldBe(0.7); + vm.Trim.CanExportSelection.ShouldBeTrue(); } [Fact] @@ -37,22 +37,22 @@ public void MarkSelection_InvertedOrder_ClearsTheOtherMark() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.SeekPosition = 0.3; - vm.MarkSelectionStartCommand.Execute(null); - vm.SeekPosition = 0.7; - vm.MarkSelectionEndCommand.Execute(null); + vm.Playback.SeekPosition = 0.3; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.7; + vm.Trim.MarkSelectionEndCommand.Execute(null); // A start at/past the end invalidates the end... - vm.SeekPosition = 0.9; - vm.MarkSelectionStartCommand.Execute(null); - vm.SelectionStartPosition.ShouldBe(0.9); - vm.HasSelectionEnd.ShouldBeFalse(); + vm.Playback.SeekPosition = 0.9; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Trim.SelectionStartPosition.ShouldBe(0.9); + vm.Trim.HasSelectionEnd.ShouldBeFalse(); // ...and an end at/before the start invalidates the start. - vm.SeekPosition = 0.1; - vm.MarkSelectionEndCommand.Execute(null); - vm.SelectionEndPosition.ShouldBe(0.1); - vm.HasSelectionStart.ShouldBeFalse(); + vm.Playback.SeekPosition = 0.1; + vm.Trim.MarkSelectionEndCommand.Execute(null); + vm.Trim.SelectionEndPosition.ShouldBe(0.1); + vm.Trim.HasSelectionStart.ShouldBeFalse(); } [Fact] @@ -62,16 +62,16 @@ public void ClearSelection_RemovesBothMarks() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.ClearSelectionCommand.CanExecute(null).ShouldBeFalse(); // nothing to clear yet + vm.Trim.ClearSelectionCommand.CanExecute(null).ShouldBeFalse(); // nothing to clear yet - vm.SeekPosition = 0.2; - vm.MarkSelectionStartCommand.Execute(null); - vm.HasAnySelectionMark.ShouldBeTrue(); + vm.Playback.SeekPosition = 0.2; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Trim.HasAnySelectionMark.ShouldBeTrue(); - vm.ClearSelectionCommand.Execute(null); + vm.Trim.ClearSelectionCommand.Execute(null); - vm.HasAnySelectionMark.ShouldBeFalse(); - vm.HasSelection.ShouldBeFalse(); + vm.Trim.HasAnySelectionMark.ShouldBeFalse(); + vm.Trim.HasSelection.ShouldBeFalse(); } [Fact] @@ -81,12 +81,12 @@ public void Selection_ClearsWhenAnotherClipIsSelected() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.SeekPosition = 0.2; - vm.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.2; + vm.Trim.MarkSelectionStartCommand.Execute(null); - vm.SelectedClip = TestClips.Create(1)[0]; + vm.Library.SelectedClip = TestClips.Create(1)[0]; - vm.HasAnySelectionMark.ShouldBeFalse(); + vm.Trim.HasAnySelectionMark.ShouldBeFalse(); } [Fact] @@ -95,20 +95,20 @@ public void TrimCommands_ReEnableWhenLoadingEndsLast() // Mirrors the real clip-open order: the controller reports Duration and IsMediaOpen while the view-model is still loading, so CanSeek only becomes true when IsLoading flips off. // Every CanSeek-gated command must be re-queried on that final transition: the Trim button shipped permanently disabled because it wasn't. var vm = CreateViewModelWithController(out var controller, out _); - vm.IsLoading = true; + vm.Playback.IsLoading = true; controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; var trimCanExecuteChanged = false; - vm.ToggleTrimmingCommand.CanExecuteChanged += (_, _) => trimCanExecuteChanged = true; + vm.Trim.ToggleTrimmingCommand.CanExecuteChanged += (_, _) => trimCanExecuteChanged = true; - vm.IsLoading = false; + vm.Playback.IsLoading = false; - vm.CanSeek.ShouldBeTrue(); + vm.Playback.CanSeek.ShouldBeTrue(); trimCanExecuteChanged.ShouldBeTrue(); - vm.ToggleTrimmingCommand.CanExecute(null).ShouldBeTrue(); - vm.MarkSelectionStartCommand.CanExecute(null).ShouldBeTrue(); - vm.MarkSelectionEndCommand.CanExecute(null).ShouldBeTrue(); + vm.Trim.ToggleTrimmingCommand.CanExecute(null).ShouldBeTrue(); + vm.Trim.MarkSelectionStartCommand.CanExecute(null).ShouldBeTrue(); + vm.Trim.MarkSelectionEndCommand.CanExecute(null).ShouldBeTrue(); } [Fact] @@ -118,12 +118,12 @@ public void MarkingAPoint_OpensTheTrimPanel() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.IsTrimming.ShouldBeFalse(); + vm.Trim.IsTrimming.ShouldBeFalse(); - vm.SeekPosition = 0.3; - vm.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.3; + vm.Trim.MarkSelectionStartCommand.Execute(null); - vm.IsTrimming.ShouldBeTrue(); + vm.Trim.IsTrimming.ShouldBeTrue(); } [Fact] @@ -133,17 +133,17 @@ public void ToggleTrimming_OpensEmpty_AndClosingDiscardsTheMarks() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.ToggleTrimmingCommand.Execute(null); - vm.IsTrimming.ShouldBeTrue(); - vm.HasAnySelectionMark.ShouldBeFalse(); + vm.Trim.ToggleTrimmingCommand.Execute(null); + vm.Trim.IsTrimming.ShouldBeTrue(); + vm.Trim.HasAnySelectionMark.ShouldBeFalse(); - vm.SeekPosition = 0.3; - vm.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.3; + vm.Trim.MarkSelectionStartCommand.Execute(null); - vm.ToggleTrimmingCommand.Execute(null); // acts as cancel while open + vm.Trim.ToggleTrimmingCommand.Execute(null); // acts as cancel while open - vm.IsTrimming.ShouldBeFalse(); - vm.HasAnySelectionMark.ShouldBeFalse(); + vm.Trim.IsTrimming.ShouldBeFalse(); + vm.Trim.HasAnySelectionMark.ShouldBeFalse(); } [Fact] @@ -153,15 +153,15 @@ public void CancelTrim_ClosesThePanelAndDiscardsTheMarks() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.SeekPosition = 0.3; - vm.MarkSelectionStartCommand.Execute(null); - vm.SeekPosition = 0.7; - vm.MarkSelectionEndCommand.Execute(null); + vm.Playback.SeekPosition = 0.3; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.7; + vm.Trim.MarkSelectionEndCommand.Execute(null); - vm.CancelTrimCommand.Execute(null); + vm.Trim.CancelTrimCommand.Execute(null); - vm.IsTrimming.ShouldBeFalse(); - vm.HasAnySelectionMark.ShouldBeFalse(); + vm.Trim.IsTrimming.ShouldBeFalse(); + vm.Trim.HasAnySelectionMark.ShouldBeFalse(); } [Fact] @@ -171,10 +171,10 @@ public void TrimPanel_ClosesWhenAnotherClipIsSelected() controller.Duration = TimeSpan.FromMinutes(1); controller.IsMediaOpen = true; - vm.ToggleTrimmingCommand.Execute(null); - vm.SelectedClip = TestClips.Create(1)[0]; + vm.Trim.ToggleTrimmingCommand.Execute(null); + vm.Library.SelectedClip = TestClips.Create(1)[0]; - vm.IsTrimming.ShouldBeFalse(); + vm.Trim.IsTrimming.ShouldBeFalse(); } [Fact] @@ -184,18 +184,18 @@ public void TrimHintText_WalksThroughStartEndExport() controller.Duration = TimeSpan.FromMinutes(2); controller.IsMediaOpen = true; - vm.TrimHintText.ShouldContain("set the start"); + vm.Trim.TrimHintText.ShouldContain("set the start"); - vm.SeekPosition = 0.25; - vm.MarkSelectionStartCommand.Execute(null); - vm.TrimHintText.ShouldContain("set the end"); + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Trim.TrimHintText.ShouldContain("set the end"); - vm.SeekPosition = 0.75; - vm.MarkSelectionEndCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); // Half of a 2:00 clip is selected. - vm.SelectionDurationText.ShouldBe("1:00"); - vm.TrimHintText.ShouldBe("1:00 selected — ready to export."); + vm.Trim.SelectionDurationText.ShouldBe("1:00"); + vm.Trim.TrimHintText.ShouldBe("1:00 selected — ready to export."); } [Fact] @@ -203,9 +203,9 @@ public void MarkSelection_RequiresSeekableMedia() { var vm = CreateViewModel(); - vm.MarkSelectionStartCommand.CanExecute(null).ShouldBeFalse(); - vm.MarkSelectionEndCommand.CanExecute(null).ShouldBeFalse(); - vm.ExportSelectionCommand.CanExecute(null).ShouldBeFalse(); + vm.Trim.MarkSelectionStartCommand.CanExecute(null).ShouldBeFalse(); + vm.Trim.MarkSelectionEndCommand.CanExecute(null).ShouldBeFalse(); + vm.Trim.ExportSelectionCommand.CanExecute(null).ShouldBeFalse(); } // Synchronous/blocking for the same thread-affinity reason as the drag-sequence test above (see RunPinnedToTestThread): the fake exporter and save picker complete synchronously. @@ -216,13 +216,13 @@ public void ExportSelection_SendsMediaTimeRangeAndActiveCameraToTheExporter() var exporter = new FakeClipExporter(); var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, exporter, _ => @"C:\out\clip.mp4"); - vm.SelectCameraViewCommand.Execute(CameraNames.Back); - vm.SeekPosition = 0.25; - vm.MarkSelectionStartCommand.Execute(null); - vm.SeekPosition = 0.75; - vm.MarkSelectionEndCommand.Execute(null); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.Back); + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); - RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); var request = exporter.Requests.ShouldHaveSingleItem(); request.Clip.ShouldBe(clipFiles.Clip); @@ -230,7 +230,7 @@ public void ExportSelection_SendsMediaTimeRangeAndActiveCameraToTheExporter() request.Start.ShouldBe(TimeSpan.FromSeconds(15)); request.End.ShouldBe(TimeSpan.FromSeconds(45)); request.OutputPath.ShouldBe(@"C:\out\clip.mp4"); - vm.IsExporting.ShouldBeFalse(); + vm.Trim.IsExporting.ShouldBeFalse(); } [Fact] @@ -240,15 +240,15 @@ public void ExportSelection_SaveDialogCanceled_DoesNotExport() var exporter = new FakeClipExporter(); var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, exporter, _ => null); - vm.SeekPosition = 0.25; - vm.MarkSelectionStartCommand.Execute(null); - vm.SeekPosition = 0.75; - vm.MarkSelectionEndCommand.Execute(null); + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); - RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); exporter.Requests.ShouldBeEmpty(); - vm.ShowErrorOverlay.ShouldBeFalse(); + vm.Error.IsVisible.ShouldBeFalse(); } [Fact] @@ -258,17 +258,17 @@ public void ExportSelection_ExporterFailure_ShowsErrorAndResetsBusyState() var exporter = new FakeClipExporter { ExceptionToThrow = new InvalidOperationException("ffmpeg exploded") }; var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, exporter, _ => @"C:\out\clip.mp4"); - vm.SeekPosition = 0.25; - vm.MarkSelectionStartCommand.Execute(null); - vm.SeekPosition = 0.75; - vm.MarkSelectionEndCommand.Execute(null); + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); - RunPinnedToTestThread(() => vm.ExportSelectionCommand.ExecuteAsync(null)); + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); - vm.ShowErrorOverlay.ShouldBeTrue(); - vm.ErrorTitle.ShouldBe("Export Failed"); - vm.ErrorDetails.ShouldContain("ffmpeg exploded"); - vm.IsExporting.ShouldBeFalse(); + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Export Failed"); + vm.Error.Details.ShouldContain("ffmpeg exploded"); + vm.Trim.IsExporting.ShouldBeFalse(); } [Fact] @@ -284,10 +284,10 @@ public async Task SaveEventClip_ExportsFrontCameraWindowAroundTheEvent() savePathPicker: _ => @"C:\out\event.mp4", exportMediaSourceBuilder: new FakeClipMediaSourceBuilder()) { - RevealInExplorer = _ => { }, + Trim = { RevealInExplorer = _ => { } }, }; - await vm.SaveEventClipCommand.ExecuteAsync(clip); + await vm.Trim.SaveEventClipCommand.ExecuteAsync(clip); var request = exporter.Requests.ShouldHaveSingleItem(); request.Camera.ShouldBe(CameraNames.Front); @@ -308,10 +308,10 @@ public async Task SaveEventClip_WindowIsClampedToTheClip() savePathPicker: _ => @"C:\out\event.mp4", exportMediaSourceBuilder: new FakeClipMediaSourceBuilder()) { - RevealInExplorer = _ => { }, + Trim = { RevealInExplorer = _ => { } }, }; - await vm.SaveEventClipCommand.ExecuteAsync(clip); + await vm.Trim.SaveEventClipCommand.ExecuteAsync(clip); var request = exporter.Requests.ShouldHaveSingleItem(); request.Start.ShouldBe(TimeSpan.Zero); @@ -331,15 +331,15 @@ public async Task SaveEventClip_BuilderThrows_ShowsErrorInsteadOfCrashing() savePathPicker: _ => @"C:\out\event.mp4", exportMediaSourceBuilder: new ThrowingClipMediaSourceBuilder(new IOException("drive gone"))) { - RevealInExplorer = _ => { }, + Trim = { RevealInExplorer = _ => { } }, }; - await vm.SaveEventClipCommand.ExecuteAsync(clip); + await vm.Trim.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(); + vm.Trim.IsExporting.ShouldBeFalse(); } private sealed class ThrowingClipMediaSourceBuilder(Exception exception) : IClipMediaSourceBuilder @@ -352,44 +352,44 @@ public void SaveEventClip_RequiresAnEventMoment() { var vm = CreateViewModel(); - vm.SaveEventClipCommand.CanExecute(ClipWithChunks(1)).ShouldBeFalse(); // no event - vm.SaveEventClipCommand.CanExecute(ClipWithEvent("clip", "sentry_aware_object_detection", "Bellevue")).ShouldBeFalse(); // event without timestamp - vm.SaveEventClipCommand.CanExecute(ClipWithChunksAndEvent(1, TimeSpan.FromSeconds(10))).ShouldBeTrue(); + vm.Trim.SaveEventClipCommand.CanExecute(ClipWithChunks(1)).ShouldBeFalse(); // no event + vm.Trim.SaveEventClipCommand.CanExecute(ClipWithEvent("clip", "sentry_aware_object_detection", "Bellevue")).ShouldBeFalse(); // event without timestamp + vm.Trim.SaveEventClipCommand.CanExecute(ClipWithChunksAndEvent(1, TimeSpan.FromSeconds(10))).ShouldBeTrue(); } [Fact] public void SpeedStepper_WalksTheLadder_AndClampsAtTheEnds() { var vm = CreateViewModel(); - vm.PlaybackSpeed.ShouldBe(1.0); + vm.Playback.PlaybackSpeed.ShouldBe(1.0); - vm.IncreaseSpeedCommand.Execute(null); - vm.PlaybackSpeed.ShouldBe(1.25); + vm.Playback.IncreaseSpeedCommand.Execute(null); + vm.Playback.PlaybackSpeed.ShouldBe(1.25); // Run the ladder up: it must stop at the top step (Flyleaf's 16x clamp). for (var i = 0; i < 20; i++) - vm.IncreaseSpeedCommand.Execute(null); - vm.PlaybackSpeed.ShouldBe(16.0); - vm.CanIncreaseSpeed.ShouldBeFalse(); - vm.IncreaseSpeedCommand.CanExecute(null).ShouldBeFalse(); + vm.Playback.IncreaseSpeedCommand.Execute(null); + vm.Playback.PlaybackSpeed.ShouldBe(16.0); + vm.Playback.CanIncreaseSpeed.ShouldBeFalse(); + vm.Playback.IncreaseSpeedCommand.CanExecute(null).ShouldBeFalse(); // And back down to the bottom step. for (var i = 0; i < 20; i++) - vm.DecreaseSpeedCommand.Execute(null); - vm.PlaybackSpeed.ShouldBe(0.25); - vm.CanDecreaseSpeed.ShouldBeFalse(); - vm.DecreaseSpeedCommand.CanExecute(null).ShouldBeFalse(); + vm.Playback.DecreaseSpeedCommand.Execute(null); + vm.Playback.PlaybackSpeed.ShouldBe(0.25); + vm.Playback.CanDecreaseSpeed.ShouldBeFalse(); + vm.Playback.DecreaseSpeedCommand.CanExecute(null).ShouldBeFalse(); } [Fact] public void ResetSpeed_ReturnsToRealtime() { var vm = CreateViewModel(); - vm.PlaybackSpeed = 8.0; + vm.Playback.PlaybackSpeed = 8.0; - vm.ResetSpeedCommand.Execute(null); + vm.Playback.ResetSpeedCommand.Execute(null); - vm.PlaybackSpeed.ShouldBe(1.0); + vm.Playback.PlaybackSpeed.ShouldBe(1.0); } [Theory] @@ -401,9 +401,9 @@ public void PlaybackSpeedText_FormatsCompactly(double speed, string expected) { var vm = CreateViewModel(); - vm.PlaybackSpeed = speed; + vm.Playback.PlaybackSpeed = speed; - vm.PlaybackSpeedText.ShouldBe(expected); + vm.Playback.PlaybackSpeedText.ShouldBe(expected); } [Fact] @@ -412,11 +412,11 @@ public async Task SpeedShortcuts_StepTheLadder() var vm = CreateViewModel(); (await vm.HandleKeyDownAsync(Key.OemPeriod, ModifierKeys.Shift)).ShouldBeTrue(); - vm.PlaybackSpeed.ShouldBe(1.25); + vm.Playback.PlaybackSpeed.ShouldBe(1.25); (await vm.HandleKeyDownAsync(Key.OemComma, ModifierKeys.Shift)).ShouldBeTrue(); (await vm.HandleKeyDownAsync(Key.OemComma, ModifierKeys.Shift)).ShouldBeTrue(); - vm.PlaybackSpeed.ShouldBe(0.75); + vm.Playback.PlaybackSpeed.ShouldBe(0.75); } [Fact] @@ -428,7 +428,7 @@ public async Task SpeedShortcuts_DoNotActBehindAboutPage() var handled = await vm.HandleKeyDownAsync(Key.OemPeriod, ModifierKeys.Shift); handled.ShouldBeFalse(); - vm.PlaybackSpeed.ShouldBe(1.0); + vm.Playback.PlaybackSpeed.ShouldBe(1.0); } [Fact] @@ -436,8 +436,30 @@ public void ChangingSpeed_FlowsToTheController() { var vm = CreateViewModelWithController(out var controller, out _); - vm.PlaybackSpeed = 4.0; + vm.Playback.PlaybackSpeed = 4.0; controller.PlaybackSpeed.ShouldBe(4.0); } + + [Fact] + public void TrimMarks_WhenRecoveryRebuildsTheClipsMedia_AreDropped() + { + // Recovery from a corrupt chunk excludes footage and shrinks the timeline, so fractions marked against the old timeline would point at different moments. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + var (vm, controller, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); + RunPinnedToTestThread(controller.PauseAsync); + vm.Playback.SeekPosition = 0.2; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.4; + vm.Trim.MarkSelectionEndCommand.Execute(null); + vm.Trim.HasSelection.ShouldBeTrue(); + + // Ending 90s into 180s of footage makes recovery exclude the middle chunk and reopen a 120s timeline. + front.RaisePositionChanged(TimeSpan.FromSeconds(90)); + front.RaiseEnded(); + RunPinnedToTestThread(controller.WhenIdleAsync); + + controller.Duration.ShouldBe(TimeSpan.FromMinutes(2)); + vm.Trim.HasAnySelectionMark.ShouldBeFalse(); + } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 30bd128..5bdca20 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -131,7 +131,7 @@ private VideoPlayerController BuildFourCameraController(FakeCameraPlayer front) savePathPicker: savePathPicker, uiInvoker: uiInvoker) { - RevealInExplorer = _ => { }, + Trim = { RevealInExplorer = _ => { } }, }; vm.InitializePlayer(); return (vm, built, front); @@ -162,10 +162,10 @@ public void NewViewModel_DefaultsToFrontCamera_AndEmptyOverlay() { var vm = CreateViewModel(); - vm.SelectedCameraView.ShouldBe(CameraNames.Front); - vm.IsGridViewSelected.ShouldBeFalse(); - vm.IsSingleCameraViewSelected.ShouldBeTrue(); - vm.CameraViewOptions.Single(option => option.ViewId == CameraNames.Front).IsSelected.ShouldBeTrue(); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Front); + vm.Cameras.IsGridViewSelected.ShouldBeFalse(); + vm.Cameras.IsSingleCameraViewSelected.ShouldBeTrue(); + vm.Cameras.CameraViewOptions.Single(option => option.ViewId == CameraNames.Front).IsSelected.ShouldBeTrue(); vm.ShowMainContent.ShouldBeTrue(); vm.ShowAboutPage.ShouldBeFalse(); @@ -173,7 +173,7 @@ public void NewViewModel_DefaultsToFrontCamera_AndEmptyOverlay() vm.HasNoClipSelected.ShouldBeTrue(); vm.ShowStatusOverlay.ShouldBeTrue(); vm.ShowVideoHosts.ShouldBeFalse(); - vm.PlayPauseIcon.ShouldBe(""); // Segoe Fluent Icons PlaySolid + vm.Playback.PlayPauseIcon.ShouldBe(""); // Segoe Fluent Icons PlaySolid } [Theory] @@ -187,13 +187,13 @@ public void SelectCameraView_SetsSelectedViewAndLabel(string cameraView, string { var vm = CreateViewModel(); - vm.SelectCameraViewCommand.Execute(cameraView); + vm.Cameras.SelectCameraViewCommand.Execute(cameraView); var expectedView = expectedLabel == "Front" ? CameraNames.Front : cameraView; - vm.SelectedCameraView.ShouldBe(expectedView); - vm.ActiveCameraLabel.ShouldBe(expectedLabel); + vm.Cameras.SelectedCameraView.ShouldBe(expectedView); + vm.Cameras.ActiveCameraLabel.ShouldBe(expectedLabel); } [Fact] @@ -201,15 +201,15 @@ public void NewViewModel_OffersGridPlusClassicFourCameras() { var vm = CreateViewModel(); - vm.CameraViewOptions.Select(option => option.ViewId).ShouldBe( + vm.Cameras.CameraViewOptions.Select(option => option.ViewId).ShouldBe( [ - MainWindowViewModel.GridCameraView, + CameraViewsViewModel.GridCameraView, CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater, ]); - vm.CameraViewOptions.Select(option => option.ShortcutNumber).ShouldBe([1, 2, 3, 4, 5]); + vm.Cameras.CameraViewOptions.Select(option => option.ShortcutNumber).ShouldBe([1, 2, 3, 4, 5]); } [Fact] @@ -217,10 +217,10 @@ public void SelectCameraView_Grid_SetsGridFlags() { var vm = CreateViewModel(); - vm.SelectCameraViewCommand.Execute("grid"); + vm.Cameras.SelectCameraViewCommand.Execute("grid"); - vm.IsGridViewSelected.ShouldBeTrue(); - vm.IsSingleCameraViewSelected.ShouldBeFalse(); + vm.Cameras.IsGridViewSelected.ShouldBeTrue(); + vm.Cameras.IsSingleCameraViewSelected.ShouldBeFalse(); } [Fact] @@ -228,12 +228,12 @@ public void SelectCameraView_Rear_SetsSingleViewFlags_AndMarksItsTile() { var vm = CreateViewModel(); - vm.SelectCameraViewCommand.Execute(CameraNames.Back); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.Back); - vm.SelectedCameraView.ShouldBe(CameraNames.Back); - vm.IsGridViewSelected.ShouldBeFalse(); - vm.IsSingleCameraViewSelected.ShouldBeTrue(); - vm.CameraViewOptions.Single(option => option.IsSelected).ViewId.ShouldBe(CameraNames.Back); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Back); + vm.Cameras.IsGridViewSelected.ShouldBeFalse(); + vm.Cameras.IsSingleCameraViewSelected.ShouldBeTrue(); + vm.Cameras.CameraViewOptions.Single(option => option.IsSelected).ViewId.ShouldBe(CameraNames.Back); } [Fact] @@ -241,11 +241,11 @@ public void SixCameraClip_OffersPillarTiles_InCanonicalOrder() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithCameras(SixCameras); + vm.Library.SelectedClip = ClipWithCameras(SixCameras); - vm.CameraViewOptions.Select(option => option.ViewId).ShouldBe( + vm.Cameras.CameraViewOptions.Select(option => option.ViewId).ShouldBe( [ - MainWindowViewModel.GridCameraView, + CameraViewsViewModel.GridCameraView, CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, @@ -253,12 +253,12 @@ public void SixCameraClip_OffersPillarTiles_InCanonicalOrder() CameraNames.LeftPillar, CameraNames.RightPillar, ]); - vm.CameraViewOptions.Select(option => option.ShortcutNumber).ShouldBe([1, 2, 3, 4, 5, 6, 7]); - vm.CameraViewOptions.Last().Label.ShouldBe("Right Pillar"); + vm.Cameras.CameraViewOptions.Select(option => option.ShortcutNumber).ShouldBe([1, 2, 3, 4, 5, 6, 7]); + vm.Cameras.CameraViewOptions.Last().Label.ShouldBe("Right Pillar"); - vm.SelectCameraViewCommand.Execute(CameraNames.LeftPillar); - vm.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); - vm.ActiveCameraLabel.ShouldBe("Left Pillar"); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.LeftPillar); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); + vm.Cameras.ActiveCameraLabel.ShouldBe("Left Pillar"); } [Fact] @@ -266,38 +266,38 @@ public void FourCameraClip_DoesNotOfferPillarTiles_AndPillarSelectionFallsBackTo { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithCameras(CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater); + vm.Library.SelectedClip = ClipWithCameras(CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater); - vm.CameraViewOptions.Count.ShouldBe(5); - vm.CameraViewOptions.ShouldAllBe(option => option.ViewId != CameraNames.LeftPillar); + vm.Cameras.CameraViewOptions.Count.ShouldBe(5); + vm.Cameras.CameraViewOptions.ShouldAllBe(option => option.ViewId != CameraNames.LeftPillar); - vm.SelectCameraViewCommand.Execute(CameraNames.LeftPillar); - vm.SelectedCameraView.ShouldBe(CameraNames.Front); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.LeftPillar); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Front); } [Fact] public void SwitchingToClipWithoutTheWatchedCamera_FallsBackToFront() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithCameras(SixCameras); - vm.SelectCameraViewCommand.Execute(CameraNames.RightPillar); + vm.Library.SelectedClip = ClipWithCameras(SixCameras); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.RightPillar); - vm.SelectedClip = ClipWithCameras(CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater); + vm.Library.SelectedClip = ClipWithCameras(CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater); - vm.SelectedCameraView.ShouldBe(CameraNames.Front); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Front); } [Fact] public void SwitchingClips_KeepsTheWatchedCamera_WhenTheNewClipHasIt() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithCameras(SixCameras); - vm.SelectCameraViewCommand.Execute(CameraNames.LeftPillar); + vm.Library.SelectedClip = ClipWithCameras(SixCameras); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.LeftPillar); - vm.SelectedClip = ClipWithCameras(SixCameras); + vm.Library.SelectedClip = ClipWithCameras(SixCameras); - vm.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); - vm.CameraViewOptions.Single(option => option.IsSelected).ViewId.ShouldBe(CameraNames.LeftPillar); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); + vm.Cameras.CameraViewOptions.Single(option => option.IsSelected).ViewId.ShouldBe(CameraNames.LeftPillar); } [Theory] @@ -312,35 +312,35 @@ public void SwitchingClips_KeepsTheWatchedCamera_WhenTheNewClipHasIt() public void CameraIdToView_MapsDocumentedEventCameraIds(int cameraId, string expectedView) { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithCameras(SixCameras); + vm.Library.SelectedClip = ClipWithCameras(SixCameras); - vm.CameraIdToView(cameraId).ShouldBe(expectedView); + vm.Cameras.CameraIdToView(cameraId).ShouldBe(expectedView); } [Fact] public void CameraIdToView_FallsBackToFront_WhenTheClipLacksThatCamera() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithCameras(CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater); + vm.Library.SelectedClip = ClipWithCameras(CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater); - vm.CameraIdToView(5).ShouldBe(CameraNames.Front); - vm.CameraIdToView(7).ShouldBe(CameraNames.Back); + vm.Cameras.CameraIdToView(5).ShouldBe(CameraNames.Front); + vm.Cameras.CameraIdToView(7).ShouldBe(CameraNames.Back); } [Fact] public async Task NumberKeys_SelectTilesByStripPosition_IncludingPillars() { var vm = CreateViewModel(); - vm.SelectedClip = ClipWithCameras(SixCameras); + vm.Library.SelectedClip = ClipWithCameras(SixCameras); (await vm.HandleKeyDownAsync(Key.D6, ModifierKeys.None)).ShouldBeTrue(); - vm.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); (await vm.HandleKeyDownAsync(Key.NumPad7, ModifierKeys.None)).ShouldBeTrue(); - vm.SelectedCameraView.ShouldBe(CameraNames.RightPillar); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.RightPillar); (await vm.HandleKeyDownAsync(Key.D1, ModifierKeys.None)).ShouldBeTrue(); - vm.SelectedCameraView.ShouldBe(MainWindowViewModel.GridCameraView); + vm.Cameras.SelectedCameraView.ShouldBe(CameraViewsViewModel.GridCameraView); } [Fact] @@ -349,7 +349,7 @@ public async Task NumberKeys_BeyondTheStrip_AreNotHandled() var vm = CreateViewModel(); // classic strip: 5 tiles, so 6 has no target (await vm.HandleKeyDownAsync(Key.D6, ModifierKeys.None)).ShouldBeFalse(); - vm.SelectedCameraView.ShouldBe(CameraNames.Front); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Front); } [Fact] @@ -358,11 +358,11 @@ public void SelectCameraView_RaisesPropertyChangedForSelectedCameraView() // The view re-parents the Flyleaf hosts when SelectedCameraView changes, so this notification is part of the view/view-model contract. var vm = CreateViewModel(); var changed = new List(); - vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + vm.Cameras.PropertyChanged += (_, e) => changed.Add(e.PropertyName); - vm.SelectCameraViewCommand.Execute("grid"); + vm.Cameras.SelectCameraViewCommand.Execute("grid"); - changed.ShouldContain(nameof(MainWindowViewModel.SelectedCameraView)); + changed.ShouldContain(nameof(CameraViewsViewModel.SelectedCameraView)); } [Fact] @@ -384,11 +384,10 @@ public void Loading_ShowsStatusOverlay_AndHidesVideo() { var vm = CreateViewModel(); - vm.IsLoading = true; + vm.Playback.IsLoading = true; vm.ShowStatusOverlay.ShouldBeTrue(); vm.ShowVideoHosts.ShouldBeFalse(); - vm.IsIndeterminateProgress.ShouldBeTrue(); } [Fact] @@ -396,7 +395,7 @@ public void Error_ShowsStatusOverlay_AndReportsError() { var vm = CreateViewModel(); - vm.ShowErrorOverlay = true; + vm.Error.IsVisible = true; vm.HasError.ShouldBeTrue(); vm.ShowStatusOverlay.ShouldBeTrue(); @@ -409,7 +408,7 @@ public void SelectingClip_HidesOverlay_AndShowsVideo() { var vm = CreateViewModel(); - vm.SelectedClip = TestClips.Create(1)[0]; + vm.Library.SelectedClip = TestClips.Create(1)[0]; vm.HasNoClipSelected.ShouldBeFalse(); vm.ShowStatusOverlay.ShouldBeFalse(); @@ -420,33 +419,33 @@ public void SelectingClip_HidesOverlay_AndShowsVideo() public void CanPlayPause_RequiresClipOrPlayback_AndNotLoading() { var vm = CreateViewModel(); - vm.CanPlayPause.ShouldBeFalse(); + vm.Playback.CanPlayPause.ShouldBeFalse(); - vm.SelectedClip = TestClips.Create(1)[0]; - vm.CanPlayPause.ShouldBeTrue(); + vm.Library.SelectedClip = TestClips.Create(1)[0]; + vm.Playback.CanPlayPause.ShouldBeTrue(); - vm.IsLoading = true; - vm.CanPlayPause.ShouldBeFalse(); + vm.Playback.IsLoading = true; + vm.Playback.CanPlayPause.ShouldBeFalse(); // Even with no selected clip, an in-flight playback keeps the toggle live. - vm.IsLoading = false; - vm.SelectedClip = null; - vm.IsPlaying = true; - vm.CanPlayPause.ShouldBeTrue(); + vm.Playback.IsLoading = false; + vm.Library.SelectedClip = null; + vm.Playback.IsPlaying = true; + vm.Playback.CanPlayPause.ShouldBeTrue(); } [Fact] public void CanStop_WhenPlayingOrLoading() { var vm = CreateViewModel(); - vm.CanStop.ShouldBeFalse(); + vm.Playback.CanStop.ShouldBeFalse(); - vm.IsPlaying = true; - vm.CanStop.ShouldBeTrue(); + vm.Playback.IsPlaying = true; + vm.Playback.CanStop.ShouldBeTrue(); - vm.IsPlaying = false; - vm.IsLoading = true; - vm.CanStop.ShouldBeTrue(); + vm.Playback.IsPlaying = false; + vm.Playback.IsLoading = true; + vm.Playback.CanStop.ShouldBeTrue(); } [Theory] @@ -456,27 +455,9 @@ public void PlayPauseIcon_ReflectsPlaybackState(bool isPlaying, string expectedI { var vm = CreateViewModel(); - vm.IsPlaying = isPlaying; + vm.Playback.IsPlaying = isPlaying; - 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(); + vm.Playback.PlayPauseIcon.ShouldBe(expectedIcon); } [Fact] @@ -484,12 +465,12 @@ public void UpdateBadge_DefaultsToUpToDate() { var vm = CreateViewModel(); - vm.IsUpdateAvailable.ShouldBeFalse(); - vm.HasUpdateBadge.ShouldBeFalse(); - vm.UpdateStatusTitle.ShouldBe("You're up to date"); - vm.UpdateStatusDetails.ShouldBe("No newer release was found."); - vm.LatestVersionText.ShouldBe("Unknown"); - vm.LatestReleaseUrl.ShouldBe(UpdateService.ReleasesPageUrl); + vm.About.IsUpdateAvailable.ShouldBeFalse(); + vm.About.HasUpdateBadge.ShouldBeFalse(); + vm.About.UpdateStatusTitle.ShouldBe("You're up to date"); + vm.About.UpdateStatusDetails.ShouldBe("No newer release was found."); + vm.About.LatestVersionText.ShouldBe("Unknown"); + vm.About.LatestReleaseUrl.ShouldBe(UpdateService.ReleasesPageUrl); } [Fact] @@ -497,14 +478,14 @@ public void UpdateBadge_ReflectsAvailableRelease() { var vm = CreateViewModel(); - vm.LatestRelease = new UpdateRelease(new Version(1, 4, 2), "v1.4.2", "https://example.com/releases/1.4.2"); - vm.IsUpdateAvailable = true; + vm.About.LatestRelease = new UpdateRelease(new Version(1, 4, 2), "v1.4.2", "https://example.com/releases/1.4.2"); + vm.About.IsUpdateAvailable = true; - vm.HasUpdateBadge.ShouldBeTrue(); - vm.UpdateStatusTitle.ShouldBe("Update available"); - vm.LatestVersionText.ShouldBe("1.4.2"); - vm.UpdateStatusDetails.ShouldBe("Version 1.4.2 is available."); - vm.LatestReleaseUrl.ShouldBe("https://example.com/releases/1.4.2"); + vm.About.HasUpdateBadge.ShouldBeTrue(); + vm.About.UpdateStatusTitle.ShouldBe("Update available"); + vm.About.LatestVersionText.ShouldBe("1.4.2"); + vm.About.UpdateStatusDetails.ShouldBe("Version 1.4.2 is available."); + vm.About.LatestReleaseUrl.ShouldBe("https://example.com/releases/1.4.2"); } [Theory] @@ -533,13 +514,13 @@ public async Task AboutPage_SwallowsPlayerShortcuts(Key key, ModifierKeys modifi { var vm = CreateViewModel(); vm.ShowAboutPage = true; - var cameraViewBefore = vm.SelectedCameraView; + var cameraViewBefore = vm.Cameras.SelectedCameraView; var handled = await vm.HandleKeyDownAsync(key, modifiers); handled.ShouldBeFalse(); - vm.SelectedCameraView.ShouldBe(cameraViewBefore); // no camera switch behind the About page - vm.IsTrimming.ShouldBeFalse(); + vm.Cameras.SelectedCameraView.ShouldBe(cameraViewBefore); // no camera switch behind the About page + vm.Trim.IsTrimming.ShouldBeFalse(); vm.ShowAboutPage.ShouldBeTrue(); // the page stays open } @@ -560,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/CameraViewOption.cs b/SentryDeck/CameraViewOption.cs index 6fab8e9..0e06087 100644 --- a/SentryDeck/CameraViewOption.cs +++ b/SentryDeck/CameraViewOption.cs @@ -16,7 +16,7 @@ public CameraViewOption(string viewId, string label, int shortcutNumber, bool is } /// - /// or a canonical name. + /// or a canonical name. /// public string ViewId { get; } diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index d36c960..bdfdbdb 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -75,10 +75,10 @@ CornerRadius="2" IsHitTestVisible="False" Opacity="0.3" - Visibility="{Binding Data.HasSelection, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> + Visibility="{Binding Data.Trim.HasSelection, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> - @@ -86,9 +86,9 @@ - - @@ -104,10 +104,10 @@ IsHitTestVisible="False" RadiusX="1" RadiusY="1" - Visibility="{Binding Data.HasSelectionStart, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> + Visibility="{Binding Data.Trim.HasSelectionStart, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> - @@ -123,10 +123,10 @@ IsHitTestVisible="False" RadiusX="1" RadiusY="1" - Visibility="{Binding Data.HasSelectionEnd, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> + Visibility="{Binding Data.Trim.HasSelectionEnd, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> - @@ -141,7 +141,7 @@ --> + ItemsSource="{Binding Data.Playback.ChunkBoundaries, Source={StaticResource VmProxy}}"> @@ -177,7 +177,7 @@ --> + ItemsSource="{Binding Data.Playback.GapPositions, Source={StaticResource VmProxy}}"> @@ -210,26 +210,26 @@ VerticalAlignment="Center" CornerRadius="1.5" IsHitTestVisible="False" - ToolTip="{Binding Data.EventMarkerTooltip, Source={StaticResource VmProxy}}" - Visibility="{Binding Data.HasEventMarker, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> + ToolTip="{Binding Data.Playback.EventMarkerTooltip, Source={StaticResource VmProxy}}" + Visibility="{Binding Data.Playback.HasEventMarker, Converter={local:BoolToVisibilityConverter}, Source={StaticResource VmProxy}}"> - - - - - -