Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions SentryDeck.Data/Playback/ClipPlaylist.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ public bool MoveTo(int index)
return true;
}

/// <summary>
/// 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.
/// </summary>
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([]);
Expand Down
76 changes: 76 additions & 0 deletions SentryDeck.Tests/ClipPlaylistTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
117 changes: 117 additions & 0 deletions SentryDeck.Tests/MainWindowViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CamClip> 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<MainWindowViewModel> LoadedViewModelAsync(IReadOnlyList<CamClip> 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()
{
Expand Down
9 changes: 9 additions & 0 deletions SentryDeck/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,15 @@
<TextBlock Text="&#xE8C8;" Style="{StaticResource AppGlyph}" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<!-- Moves the whole clip folder to the Windows Recycle Bin (recoverable) so the timeline can be tidied in place. -->
<MenuItem Header="Delete to Recycle Bin"
Command="{Binding PlacementTarget.Tag.DeleteClipCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}">
<MenuItem.Icon>
<TextBlock Text="&#xE74D;" Style="{StaticResource AppGlyph}" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</Window.Resources>

Expand Down
74 changes: 74 additions & 0 deletions SentryDeck/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1029,6 +1030,24 @@ private static string PickSavePathWithDialog(string defaultFileName)
internal Action<string> RevealInExplorer { get; set; } = path =>
Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = true });

/// <summary>
/// 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.
/// </summary>
internal Func<CamClip, bool> 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;

/// <summary>
/// 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.
/// </summary>
internal Action<string> 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)
Expand Down Expand Up @@ -1721,6 +1740,61 @@ private void CopyTimestamp(CamClip clip)
}
}

/// <summary>
/// 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.
/// </summary>
[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)
{
Expand Down
7 changes: 7 additions & 0 deletions SentryDeck/Playback/VideoPlayerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,13 @@ public void LoadClips(IEnumerable<CamClip> clips)
Playlist.SetClips(clips);
}

/// <summary>
/// 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.
/// </summary>
public void RemoveClip(CamClip clip) => Playlist.RemoveClip(clip);

public void Dispose()
{
if (_isDisposed)
Expand Down