diff --git a/SentryDeck.Data/Playback/ClipPlaylist.cs b/SentryDeck.Data/Playback/ClipPlaylist.cs
index 5e7258c..d7e41a7 100644
--- a/SentryDeck.Data/Playback/ClipPlaylist.cs
+++ b/SentryDeck.Data/Playback/ClipPlaylist.cs
@@ -61,6 +61,36 @@ public bool MoveTo(int index)
return true;
}
+ ///
+ /// Removes a clip from the playlist while keeping the current selection pointed at the same
+ /// clip. Removing the current clip itself clears the selection (the caller owns stopping
+ /// playback); a clip before the current one shifts the index down to compensate. Returns false
+ /// when the clip isn't in the playlist.
+ ///
+ public bool RemoveClip(CamClip clip)
+ {
+ var index = clip is null ? -1 : _clips.IndexOf(clip);
+ if (index < 0)
+ {
+ return false;
+ }
+
+ _clips.RemoveAt(index);
+
+ if (index < _currentIndex)
+ {
+ _currentIndex--;
+ }
+ else if (index == _currentIndex)
+ {
+ _currentIndex = -1;
+ }
+
+ Log.Debug("Removed clip from playlist. ClipName={ClipName}; ClipCount={ClipCount}", clip.Name, _clips.Count);
+ PlaylistChanged?.Invoke(this, EventArgs.Empty);
+ return true;
+ }
+
public void Clear()
{
SetClips([]);
diff --git a/SentryDeck.Tests/ClipPlaylistTests.cs b/SentryDeck.Tests/ClipPlaylistTests.cs
index 0026511..f38aa3d 100644
--- a/SentryDeck.Tests/ClipPlaylistTests.cs
+++ b/SentryDeck.Tests/ClipPlaylistTests.cs
@@ -95,4 +95,80 @@ public void SetClips_RaisesPlaylistAndCurrentClipEvents()
playlistChanged.ShouldBe(1);
currentChanged.ShouldBe(1);
}
+
+ [Fact]
+ public void RemoveClip_BeforeCurrent_KeepsCurrentClip_AndShiftsIndex()
+ {
+ var playlist = new ClipPlaylist();
+ var clips = TestClips.Create(3);
+ playlist.SetClips(clips);
+ playlist.MoveTo(2);
+
+ var removed = playlist.RemoveClip(clips[0]);
+
+ removed.ShouldBeTrue();
+ playlist.Clips.ShouldBe(new[] { clips[1], clips[2] });
+ playlist.CurrentIndex.ShouldBe(1);
+ playlist.CurrentClip.ShouldBe(clips[2]); // same clip, index slid down by one
+ }
+
+ [Fact]
+ public void RemoveClip_AfterCurrent_LeavesCurrentAndIndexUntouched()
+ {
+ var playlist = new ClipPlaylist();
+ var clips = TestClips.Create(3);
+ playlist.SetClips(clips);
+ playlist.MoveTo(0);
+
+ playlist.RemoveClip(clips[2]);
+
+ playlist.CurrentIndex.ShouldBe(0);
+ playlist.CurrentClip.ShouldBe(clips[0]);
+ playlist.HasNext.ShouldBeTrue(); // clips[1] still follows
+ }
+
+ [Fact]
+ public void RemoveClip_TheCurrentClip_ClearsSelection()
+ {
+ var playlist = new ClipPlaylist();
+ var clips = TestClips.Create(3);
+ playlist.SetClips(clips);
+ playlist.MoveTo(1);
+
+ playlist.RemoveClip(clips[1]);
+
+ playlist.CurrentIndex.ShouldBe(-1);
+ playlist.CurrentClip.ShouldBeNull();
+ playlist.Clips.ShouldBe(new[] { clips[0], clips[2] });
+ }
+
+ [Fact]
+ public void RemoveClip_NotInPlaylist_ReturnsFalse_AndChangesNothing()
+ {
+ var playlist = new ClipPlaylist();
+ var clips = TestClips.Create(2);
+ playlist.SetClips(clips);
+ playlist.MoveTo(1);
+ var stranger = TestClips.Create(1)[0];
+
+ var removed = playlist.RemoveClip(stranger);
+
+ removed.ShouldBeFalse();
+ playlist.Clips.ShouldBe(clips);
+ playlist.CurrentIndex.ShouldBe(1);
+ }
+
+ [Fact]
+ public void RemoveClip_RaisesPlaylistChanged()
+ {
+ var playlist = new ClipPlaylist();
+ var clips = TestClips.Create(2);
+ playlist.SetClips(clips);
+ var playlistChanged = 0;
+ playlist.PlaylistChanged += (_, _) => playlistChanged++;
+
+ playlist.RemoveClip(clips[0]);
+
+ playlistChanged.ShouldBe(1);
+ }
}
diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs
index e00c986..b891925 100644
--- a/SentryDeck.Tests/MainWindowViewModelTests.cs
+++ b/SentryDeck.Tests/MainWindowViewModelTests.cs
@@ -583,6 +583,123 @@ public void ShowOnMap_DisabledWithoutCoordinates()
vm.ShowOnMapCommand.CanExecute(withLocation).ShouldBeTrue();
}
+ // --- Delete to Recycle Bin: the injectable confirm/recycle delegates keep this off the shell ---
+
+ private static List ClipsWithDistinctPaths(int count) =>
+ Enumerable.Range(0, count)
+ .Select(index => new CamClip(
+ $@"C:\clips\clip{index}",
+ $"Clip {index}",
+ new DateTime(2025, 1, 1, 12, 0, 0).AddMinutes(index),
+ [],
+ camEvent: null))
+ .ToList();
+
+ private static async Task LoadedViewModelAsync(IReadOnlyList clips)
+ {
+ var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips);
+ await vm.LoadClipsAsync(new[] { "root" });
+ return vm;
+ }
+
+ [Fact]
+ public void DeleteClipCommand_CanExecute_RequiresAClip()
+ {
+ var vm = CreateViewModel();
+
+ vm.DeleteClipCommand.CanExecute(null).ShouldBeFalse();
+ vm.DeleteClipCommand.CanExecute(TestClips.Create(1)[0]).ShouldBeTrue();
+ }
+
+ [Fact]
+ 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;
+
+ var target = vm.FilteredClips.Single(clip => clip.Name == "Clip 1");
+ await vm.DeleteClipCommand.ExecuteAsync(target);
+
+ recycledPath.ShouldBe(target.FullPath);
+ vm.FilteredClips.ShouldNotContain(target);
+ vm.ClipCount.ShouldBe(2);
+ }
+
+ [Fact]
+ 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++;
+
+ var target = vm.FilteredClips[0];
+ await vm.DeleteClipCommand.ExecuteAsync(target);
+
+ recycleCalls.ShouldBe(0);
+ vm.ClipCount.ShouldBe(2);
+ vm.FilteredClips.ShouldContain(target);
+ }
+
+ [Fact]
+ public async Task DeleteClip_TheSelectedClip_ClearsSelectionAndNowPlaying()
+ {
+ var clips = ClipsWithDistinctPaths(2);
+ var vm = await LoadedViewModelAsync(clips);
+ vm.ConfirmDeleteClip = _ => true;
+ vm.RecycleClipFolder = _ => { };
+
+ var target = vm.FilteredClips[0];
+ vm.SelectedClip = target; // sets NowPlayingClip too (see OnSelectedClipChanged)
+ vm.NowPlayingClip.ShouldBe(target);
+
+ await vm.DeleteClipCommand.ExecuteAsync(target);
+
+ vm.SelectedClip.ShouldBeNull();
+ vm.NowPlayingClip.ShouldBeNull();
+ vm.FilteredClips.ShouldNotContain(target);
+ }
+
+ [Fact]
+ public async Task DeleteClip_NotTheSelectedClip_LeavesSelectionIntact()
+ {
+ var clips = ClipsWithDistinctPaths(3);
+ var vm = await LoadedViewModelAsync(clips);
+ vm.ConfirmDeleteClip = _ => true;
+ vm.RecycleClipFolder = _ => { };
+
+ var selected = vm.FilteredClips.Single(clip => clip.Name == "Clip 2");
+ var victim = vm.FilteredClips.Single(clip => clip.Name == "Clip 0");
+ vm.SelectedClip = selected;
+
+ await vm.DeleteClipCommand.ExecuteAsync(victim);
+
+ vm.SelectedClip.ShouldBe(selected);
+ vm.FilteredClips.ShouldNotContain(victim);
+ vm.ClipCount.ShouldBe(2);
+ }
+
+ [Fact]
+ 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.");
+
+ var target = vm.FilteredClips[0];
+ await vm.DeleteClipCommand.ExecuteAsync(target);
+
+ vm.ShowErrorOverlay.ShouldBeTrue();
+ vm.ErrorTitle.ShouldBe("Delete Failed");
+ vm.ClipCount.ShouldBe(2);
+ vm.FilteredClips.ShouldContain(target);
+ }
+
[Fact]
public async Task FilteredClips_NoMatch_IsEmpty()
{
diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml
index 9b59611..28321da 100644
--- a/SentryDeck/MainWindow.xaml
+++ b/SentryDeck/MainWindow.xaml
@@ -537,6 +537,15 @@
+
+
+
diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs
index 75f4af8..e8460ff 100644
--- a/SentryDeck/MainWindowViewModel.cs
+++ b/SentryDeck/MainWindowViewModel.cs
@@ -9,6 +9,7 @@
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using Microsoft.VisualBasic.FileIO;
using Microsoft.Win32;
using Serilog;
@@ -1029,6 +1030,24 @@ private static string PickSavePathWithDialog(string defaultFileName)
internal Action RevealInExplorer { get; set; } = path =>
Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true });
+ ///
+ /// Asks the user to confirm sending a clip to the Recycle Bin. Returns true to proceed.
+ /// Overridable for tests; defaults to a yes/no message box.
+ ///
+ internal Func ConfirmDeleteClip { get; set; } = clip =>
+ MessageBox.Show(
+ $"Move this clip to the Recycle Bin?\n\n{clip.Name}\n{clip.FullPath}",
+ "Delete clip",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning) == MessageBoxResult.Yes;
+
+ ///
+ /// Sends a clip folder to the Windows Recycle Bin (recoverable). Overridable for tests; defaults
+ /// to the shell recycle operation, which surfaces its own error dialog if a file is in use.
+ ///
+ internal Action RecycleClipFolder { get; set; } = path =>
+ FileSystem.DeleteDirectory(path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
+
private static string FormatTimeSpanForFileName(TimeSpan ts) => FormatTimeSpan(ts).Replace(':', '.');
private static string SanitizeFileName(string name)
@@ -1721,6 +1740,61 @@ private void CopyTimestamp(CamClip clip)
}
}
+ ///
+ /// Sends the clip's folder to the Recycle Bin so the timeline can be tidied without leaving the
+ /// app. Confirms first, then — if the clip is the one currently open — stops playback so Flyleaf
+ /// releases its file handles before the shell tries to recycle the (otherwise locked) folder.
+ ///
+ [RelayCommand(CanExecute = nameof(CanUseClip))]
+ private async Task DeleteClipAsync(CamClip clip)
+ {
+ if (clip is null || !ConfirmDeleteClip(clip))
+ {
+ return;
+ }
+
+ // Flyleaf keeps the current clip's camera files open; Windows can't recycle a folder whose
+ // files are still locked, so stop and close the players before deleting it.
+ var isCurrent = ReferenceEquals(SelectedClip, clip)
+ || ReferenceEquals(NowPlayingClip, clip)
+ || _playerController?.CurrentClip == clip;
+ if (isCurrent && _playerController is not null)
+ {
+ await _playerController.StopAsync();
+ SeekPosition = 0;
+ }
+
+ try
+ {
+ Log.Information("Deleting clip to Recycle Bin. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath);
+ await Task.Run(() => RecycleClipFolder(clip.FullPath));
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failed to delete clip. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath);
+ ShowError("Delete Failed", $"Could not delete clip: {clip.Name}\n\nError: {ex.Message}");
+ return;
+ }
+
+ // Drop it from the sidebar list and keep the player's Next/Previous playlist in sync.
+ _allClips.Remove(clip);
+ _playerController?.RemoveClip(clip);
+
+ if (ReferenceEquals(NowPlayingClip, clip))
+ {
+ NowPlayingClip = null;
+ }
+
+ if (ReferenceEquals(SelectedClip, clip))
+ {
+ SelectedClip = null;
+ }
+
+ OnPropertyChanged(nameof(FilteredClips));
+ OnPropertyChanged(nameof(ClipCount));
+ RefreshClipState();
+ }
+
[RelayCommand(CanExecute = nameof(CanShowOnMap))]
private void ShowOnMap(CamClip clip)
{
diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs
index 97bc4d0..a6d48f0 100644
--- a/SentryDeck/Playback/VideoPlayerController.cs
+++ b/SentryDeck/Playback/VideoPlayerController.cs
@@ -385,6 +385,13 @@ public void LoadClips(IEnumerable clips)
Playlist.SetClips(clips);
}
+ ///
+ /// Drops a single clip from the playlist so Next/Previous navigation stays aligned with a
+ /// trimmed clip list (e.g. after the user deletes a clip). Does not touch what's playing;
+ /// when the removed clip is the current one the caller is responsible for having stopped it.
+ ///
+ public void RemoveClip(CamClip clip) => Playlist.RemoveClip(clip);
+
public void Dispose()
{
if (_isDisposed)