From 930ba3445902d630753be65dcde5bc05f84a16e7 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:14:55 -0500 Subject: [PATCH 01/73] Keep the event of clips stamped just past their last frame A saved clip whose event.json time fell a moment after its last recorded frame opened at 0:00 instead of just before the event. It showed no event marker, Jump to event was disabled, and "Save event clip" failed with "The event moment isn't within this clip's saved footage." In the tested library that was 67 of 173 clips. Tesla stamps event.json at the save moment, and both it and the chunk file names are truncated to whole seconds, so the event lands up to about 2 s past the probed end of the footage as often as just inside it. ClipMediaSource.ToMediaTime mapped only instants inside the footage or exactly at its end, so these events had no media time at all. An instant within GapThreshold (5 s) after the last chunk's end now maps to the end of the footage. These clips now open 10 s before the end with the event marker there, and Save event clip exports the last 30 s. Instants further past the end still map to nothing. Found in v1.1.0 local testing: F58, F67, F100. --- SentryDeck.Data/Playback/ClipMediaSource.cs | 8 ++++++-- SentryDeck.Tests/ClipMediaSourceTrimTests.cs | 16 ++++++++++++++++ .../FfconcatMediaSourceBuilderTests.cs | 16 ++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/SentryDeck.Data/Playback/ClipMediaSource.cs b/SentryDeck.Data/Playback/ClipMediaSource.cs index d22e6f6..1e4a1ea 100644 --- a/SentryDeck.Data/Playback/ClipMediaSource.cs +++ b/SentryDeck.Data/Playback/ClipMediaSource.cs @@ -57,7 +57,8 @@ public IReadOnlyList GapPositions } /// - /// Maps a wall-clock instant to the corresponding media-time position, or null when it falls outside the clip entirely (before the clip's original start or after the last chunk's probed end). + /// Maps a wall-clock instant to the corresponding media-time position, or null when it falls outside the clip entirely (before the clip's original start or more than after the last chunk's probed end). + /// An instant within after the last chunk's probed end maps to the end of the footage, since that is where Tesla's whole-second timestamps put a saved clip's own event. /// An instant that falls inside a gap between chunks (deleted/corrupt/excluded footage, or a Sentry idle period) has no media time of its own -- since playback skips straight over the gap, this returns the position of the chunk that resumes right after it, which is the first media time where footage anywhere near that moment is visible. /// This mirrors the existing "jump to event" behavior of landing on the nearest available frame rather than refusing to show a marker at all. /// A leading gap gets the same treatment: an instant inside excluded leading footage (at or after but before the first included chunk) snaps forward to media time zero. @@ -98,7 +99,10 @@ public IReadOnlyList GapPositions var lastIndex = ChunkTimestamps.Count - 1; var lastChunkEnd = ChunkTimestamps[lastIndex] + ChunkDurations[lastIndex]; - return wallClock == lastChunkEnd ? ChunkStarts[lastIndex] + ChunkDurations[lastIndex] : null; + // Tesla stamps event.json at the save moment, and both it and the chunk file names are truncated to whole seconds, so a saved clip's event lands up to about 2 s past the probed end as often as just inside it. + // Treating that ordinary skew as outside the clip would cost the clip its event marker, its event start position and Save event clip. + // Anything later than GapThreshold is footage that was never saved, so it stays unmapped. + return wallClock - lastChunkEnd <= GapThreshold ? ChunkStarts[lastIndex] + ChunkDurations[lastIndex] : null; } /// diff --git a/SentryDeck.Tests/ClipMediaSourceTrimTests.cs b/SentryDeck.Tests/ClipMediaSourceTrimTests.cs index f9efb5c..b439c32 100644 --- a/SentryDeck.Tests/ClipMediaSourceTrimTests.cs +++ b/SentryDeck.Tests/ClipMediaSourceTrimTests.cs @@ -163,6 +163,22 @@ public void ToMediaTime_AtExactClipEnd_ReturnsDuration() ThreeChunkSource().ToMediaTime(FirstTimestamp.AddMinutes(3)).ShouldBe(TimeSpan.FromSeconds(180)); } + [Fact] + public void ToMediaTime_ExactlyTheGapThresholdAfterClipEnd_ReturnsDuration() + { + // An instant this close to the end is the ordinary clock skew between event.json and the footage, so the event still belongs at the last frame. + ThreeChunkSource().ToMediaTime(FirstTimestamp.AddMinutes(3) + ClipMediaSource.GapThreshold).ShouldBe(TimeSpan.FromSeconds(180)); + } + + [Fact] + public void ToMediaTime_JustOverTheGapThresholdAfterClipEnd_ReturnsNull() + { + // Any later is footage that was never saved, so pinning it to the last frame would claim a moment the clip doesn't show. + var instant = FirstTimestamp.AddMinutes(3) + ClipMediaSource.GapThreshold + TimeSpan.FromTicks(1); + + ThreeChunkSource().ToMediaTime(instant).ShouldBeNull(); + } + [Fact] public void GapPositions_GapExactlyAtThreshold_IsNotAGap() { diff --git a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs index 7d8747b..b661596 100644 --- a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs +++ b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs @@ -520,12 +520,24 @@ public void ToMediaTime_InstantBeforeClipStart_StaysNullEvenWithExcludedLeadingC } [Fact] - public void ToMediaTime_InstantAfterClipEnd_ReturnsNull() + public void ToMediaTime_EventStampedJustAfterClipEnd_MapsToTheEndOfTheFootage() { using var clipFiles = TestClipFiles.Create(chunkCount: 2); var mediaSource = Build(clipFiles.Clip); - var instant = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(61); + // Real saved clips put their event up to about 1.9 s past the last chunk's probed end, because Tesla stamps the save moment and both timestamps are truncated to whole seconds. + var instant = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(61.88); + + mediaSource.ToMediaTime(instant).ShouldBe(mediaSource.Duration); + } + + [Fact] + public void ToMediaTime_InstantFarPastClipEnd_ReturnsNull() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + var mediaSource = Build(clipFiles.Clip); + + var instant = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(90); mediaSource.ToMediaTime(instant).ShouldBeNull(); } From dfd094009ece6d203dd8f04e42100f8f50adea15 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:20:10 -0500 Subject: [PATCH 02/73] Play the intact numbered copy when the original footage file is damaged On a drive that was copied or merged, a camera file can have a truncated original (no moov) beside an intact numbered copy such as front-2.mp4. The app always played the original, so a single-chunk clip failed with "No front camera footage found." and in a longer clip that minute silently disappeared, although readable footage was on disk. A damaged rear or side original likewise cut that camera short. CamChunk picked between a file and its copies only by whether the original was empty, so a non-empty but unreadable original always won, and the playlist builder then dropped it. When a camera has several files at one timestamp, the first one with a playable duration now wins, originals before copies, per camera. If none is playable, the old rule still applies. The playability check (a positive duration of at most 10 minutes) moved from the playlist builder to Mp4DurationReader.TryReadChunkDuration, so the file chosen at scan time is never one the builder rejects. Only cameras with more than one file are probed, so scanning cost is unchanged for ordinary clips. Found in v1.1.0 local testing: F14. --- SentryDeck.Data/CamChunk.cs | 17 +++++--- .../Playback/FfconcatMediaSourceBuilder.cs | 10 +---- SentryDeck.Data/Playback/Mp4DurationReader.cs | 16 ++++++++ SentryDeck.Tests/CamChunkTests.cs | 41 +++++++++++++++++++ .../FfconcatMediaSourceBuilderTests.cs | 16 ++++++++ 5 files changed, 87 insertions(+), 13 deletions(-) diff --git a/SentryDeck.Data/CamChunk.cs b/SentryDeck.Data/CamChunk.cs index 37411ea..21043c9 100644 --- a/SentryDeck.Data/CamChunk.cs +++ b/SentryDeck.Data/CamChunk.cs @@ -26,7 +26,10 @@ public CamChunk(DateTime timestamp, IEnumerable files) // Keyed by camera name, one file per camera. // Several files can claim one camera at a timestamp: numbered copies ("front-2.mp4") left by copying a drive, or a rear_view -> back alias collision. // An unguarded ToDictionary would throw, and since CamClip.TryMap swallows that, the WHOLE clip folder would be silently dropped. - // The original wins, unless it's empty and a copy isn't; ties keep enumeration order. + // The first playable file wins, originals before copies. + // A copied or merged drive can hold a truncated original beside an intact copy, and the player drops a file it can't play, so going by name alone would lose footage that is on disk. + // When nothing is playable, the original wins unless it's empty and a copy isn't; ties keep enumeration order. + // Only cameras with several files are probed, so a scan doesn't open every file on the drive. private static IReadOnlyDictionary BuildFileMap(IEnumerable files) { var map = new Dictionary(); @@ -38,16 +41,20 @@ private static IReadOnlyDictionary BuildFileMap(IEnumerable file.CopyNumber) .ToList(); - map[group.Key] = candidates[0]; + var chosen = candidates.Count == 1 + ? candidates[0] + : candidates.FirstOrDefault(file => Mp4DurationReader.TryReadChunkDuration(file.FullPath) is not null) ?? candidates[0]; + + map[group.Key] = chosen; if (candidates.Count > 1) { Log.Debug( "Several files for one camera at one timestamp; using one and ignoring the rest. Camera={Camera}; Timestamp={Timestamp}; Using={UsedPath}; Ignored={IgnoredPaths}", group.Key, - candidates[0].Timestamp, - candidates[0].FullPath, - candidates.Skip(1).Select(file => file.FullPath).ToArray()); + chosen.Timestamp, + chosen.FullPath, + candidates.Where(file => !ReferenceEquals(file, chosen)).Select(file => file.FullPath).ToArray()); } } diff --git a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs index 0251771..a672f1e 100644 --- a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs +++ b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs @@ -125,11 +125,6 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice return new ClipMediaSource(duration, chunkStarts, playlistPaths, autoExcludedIndices, chunkTimestamps, chunkDurations, clipStartTimestamp); } - /// - /// Tesla writes chunks of about a minute; a header claiming far more is corrupt, and trusting it would stretch the clip's timeline by hours of footage that isn't there. - /// - private static readonly TimeSpan MaxPlausibleChunkDuration = TimeSpan.FromMinutes(10); - private static TimeSpan? ProbeFrontChunkDuration(CamChunk chunk) { if (!chunk.Files.TryGetValue(CameraNames.Front, out var frontFile)) @@ -140,8 +135,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice return null; } - var probed = Mp4DurationReader.TryReadDuration(frontFile.FullPath); - if (probed is { } duration && duration > TimeSpan.Zero && duration <= MaxPlausibleChunkDuration) + if (Mp4DurationReader.TryReadChunkDuration(frontFile.FullPath) is { } duration) { return duration; } @@ -155,7 +149,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice private static bool IsProbeable(string path) { - return Mp4DurationReader.TryReadDuration(path) is { } duration && duration > TimeSpan.Zero && duration <= MaxPlausibleChunkDuration; + return Mp4DurationReader.TryReadChunkDuration(path) is not null; } private static void WritePlaylist(string path, IReadOnlyList<(string FilePath, TimeSpan Duration)> entries) diff --git a/SentryDeck.Data/Playback/Mp4DurationReader.cs b/SentryDeck.Data/Playback/Mp4DurationReader.cs index 0ba0c87..d58df3b 100644 --- a/SentryDeck.Data/Playback/Mp4DurationReader.cs +++ b/SentryDeck.Data/Playback/Mp4DurationReader.cs @@ -10,6 +10,22 @@ public static class Mp4DurationReader private const int BoxHeaderSize = 8; private const int LargeSizeFieldSize = 8; + /// + /// Tesla writes chunks of about a minute; a header claiming far more is corrupt, and trusting it would stretch the clip's timeline by hours of footage that isn't there. + /// + private static readonly TimeSpan MaxPlausibleChunkDuration = TimeSpan.FromMinutes(10); + + /// + /// Returns the duration of a dashcam chunk file, or null when it can't be played as one: no readable duration, a zero one, or one no real chunk has. + /// Choosing between a file and its numbered copy and building the playlist both go through this, so a file picked as playable is never dropped as unplayable later. + /// + public static TimeSpan? TryReadChunkDuration(string path) + { + return TryReadDuration(path) is { } duration && duration > TimeSpan.Zero && duration <= MaxPlausibleChunkDuration + ? duration + : null; + } + /// /// Returns the duration encoded in the file's "moov/mvhd" box, or null if it cannot be determined (missing boxes, malformed data, or any IO error). /// diff --git a/SentryDeck.Tests/CamChunkTests.cs b/SentryDeck.Tests/CamChunkTests.cs index 6e205a8..af3b77c 100644 --- a/SentryDeck.Tests/CamChunkTests.cs +++ b/SentryDeck.Tests/CamChunkTests.cs @@ -52,4 +52,45 @@ public void Map_KeepsUnknownCameraSuffix() chunks.ShouldHaveSingleItem(); chunks[0].Files.Keys.ShouldContain("front_bumper"); } + + [Theory] + [InlineData(CameraNames.Front)] + [InlineData(CameraNames.Back)] + public void Map_TruncatedOriginalWithAReadableCopy_UsesTheCopy(string camera) + { + // A copied or merged drive can hold a truncated original beside an intact "-2" copy; keeping the original by name dropped that minute at play time. + using var temp = new TempDirectory(); + temp.Write("2025-01-01_12-00-00-front.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + temp.Write($"2025-01-01_12-00-00-{camera}.mp4", TestMp4.BuildTruncated(keepBytes: 30)); + temp.Write($"2025-01-01_12-00-00-{camera}-2.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + + var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem(); + + chunk.Files[camera].CopyNumber.ShouldBe(2); + } + + [Fact] + public void Map_OriginalClaimingHoursOfFootageWithAReadableCopy_UsesTheCopy() + { + // The choice must agree with what the playlist builder accepts, or the original would win here and then be dropped as implausible. + using var temp = new TempDirectory(); + temp.Write("2025-01-01_12-00-00-front.mp4", TestMp4.Build(version: 0, timescale: 1, duration: uint.MaxValue)); + temp.Write("2025-01-01_12-00-00-front-2.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + + var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem(); + + chunk.Files[CameraNames.Front].CopyNumber.ShouldBe(2); + } + + [Fact] + public void Map_ReadableOriginalAndReadableCopy_PrefersTheOriginal() + { + using var temp = new TempDirectory(); + temp.Write("2025-01-01_12-00-00-front-2.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + temp.Write("2025-01-01_12-00-00-front.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + + var chunk = CamChunk.Map(temp.Path).ShouldHaveSingleItem(); + + chunk.Files[CameraNames.Front].CopyNumber.ShouldBe(0); + } } diff --git a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs index b661596..a31347c 100644 --- a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs +++ b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs @@ -364,6 +364,22 @@ public void Build_SideFileUnprobeable_TruncatesOnlyThatCamera() frontContent.ShouldContain(clipFiles.GetFfconcatPath(2, CameraNames.Front)); } + [Fact] + public void Build_TruncatedFrontWithAReadableNumberedCopy_PlaysTheCopy() + { + // A single-chunk clip used to fail with "No front camera footage found." while an intact front-2.mp4 sat beside the damaged original. + using var temp = new TempDirectory(); + temp.Write("2025-04-11_14-34-07-front.mp4", TestMp4.BuildTruncated(keepBytes: 30)); + var copyPath = temp.Write("2025-04-11_14-34-07-front-2.mp4", TestMp4.BuildWithDuration(TimeSpan.FromSeconds(60))); + var clip = CamClip.Map(temp.Path); + + var mediaSource = Build(clip); + + mediaSource.AutoExcludedChunkIndices.ShouldBeEmpty(); + mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(60)); + File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]).ShouldContain(FfconcatMediaSourceBuilder.EscapeConcatPath(copyPath)); + } + // --- Chunks whose probed durations differ from their nominal one-minute spacing --- [Fact] From 8f2670236765c0eca3bc6846882da0e4b1b603ad Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:26:55 -0500 Subject: [PATCH 03/73] Stop blaming the car's encryption for zero-filled or text footage files A clip whose front file was zero-filled (a power-loss leftover) or plain text renamed to .mp4 was reported as "encrypted by the vehicle", with advice to turn off Encrypt Dashcam Recordings in the car. That sent the user to change a car setting for a file that is simply damaged. EncryptedClipDetector treated any file of 8 or more bytes whose first box type wasn't a known MP4 box as encrypted, so a header of zeros or ASCII text qualified, and a single-chunk clip needs only one such file. The detector now also requires the first 4 KiB to vary the way ciphertext does: a file where one byte value fills more than half the sample (zero or 0xFF filler) or that is at least 90% printable text is no longer called encrypted. Those clips take the ordinary unreadable-footage path instead. Random encryption-shaped containers are still detected. Found in v1.1.0 local testing: F15. --- .../Playback/EncryptedClipDetector.cs | 56 +++++++++++++++++-- .../EncryptedClipDetectorTests.cs | 44 +++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/SentryDeck.Data/Playback/EncryptedClipDetector.cs b/SentryDeck.Data/Playback/EncryptedClipDetector.cs index 8d66872..ef22b6a 100644 --- a/SentryDeck.Data/Playback/EncryptedClipDetector.cs +++ b/SentryDeck.Data/Playback/EncryptedClipDetector.cs @@ -29,7 +29,18 @@ public static class EncryptedClipDetector ]; /// - /// True when the clip's front-camera files are all present with content but none starts like an MP4, the signature of a drive written with encryption enabled. + /// How much of a file's start to inspect. + /// It spans the encrypted container's 20-byte header and 16-byte IV plus most of its first 4 KiB ciphertext chunk, so the header is a small part of a real encrypted file's sample and the rest looks uniformly random. + /// + private const int SampleLength = 4096; + + /// + /// The size of an ISO-BMFF box header: a 4-byte size followed by the 4-byte type. + /// + private const int BoxHeaderLength = 8; + + /// + /// True when the clip's front-camera files are all present with content and every one starts with what looks like ciphertext instead of an MP4 box, the signature of a drive written with encryption enabled. /// A merely corrupt or truncated clip still has a valid ftyp header on at least some chunks, so it stays on the ordinary unreadable-file path. /// public static bool LooksEncrypted(CamClip clip) @@ -60,7 +71,7 @@ public static bool LooksEncrypted(CamClip clip) } /// - /// True when the file has content but does not start with a recognizable MP4 box. + /// True when the file has content that neither starts with a recognizable MP4 box nor looks like filler or text, leaving ciphertext as the likely explanation. /// public static bool LooksEncrypted(string path) { @@ -68,15 +79,17 @@ public static bool LooksEncrypted(string path) { using var stream = File.OpenRead(path); - Span header = stackalloc byte[8]; - if (stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) < header.Length) + Span sample = stackalloc byte[SampleLength]; + var length = stream.ReadAtLeast(sample, sample.Length, throwOnEndOfStream: false); + if (length < BoxHeaderLength) { // Shorter than one box header: a truncated write, not an encrypted container (those carry a fixed header plus at least one 4 KiB payload chunk). return false; } - var leadingBoxType = Encoding.ASCII.GetString(header[4..]); - return !KnownLeadingBoxTypes.Contains(leadingBoxType); + sample = sample[..length]; + var leadingBoxType = Encoding.ASCII.GetString(sample[4..BoxHeaderLength]); + return !KnownLeadingBoxTypes.Contains(leadingBoxType) && LooksLikeCiphertext(sample); } catch (Exception ex) { @@ -85,4 +98,35 @@ public static bool LooksEncrypted(string path) return false; } } + + /// + /// True when the bytes vary the way ciphertext does. + /// A power cut can leave clusters allocated but never written, which read back as one repeated byte (usually zero), and a text file renamed to .mp4 is nearly all printable characters. + /// Neither comes from the car's encryption, and calling them encrypted would send the user to change a car setting instead of treating the file as damaged. + /// + private static bool LooksLikeCiphertext(ReadOnlySpan sample) + { + Span counts = stackalloc int[256]; + var mostCommonCount = 0; + var textCount = 0; + + foreach (var value in sample) + { + mostCommonCount = Math.Max(mostCommonCount, ++counts[value]); + + if (value is (>= 0x20 and < 0x7F) or (byte)'\t' or (byte)'\n' or (byte)'\r') + { + textCount++; + } + } + + // Ciphertext repeats each byte value about once per 256 bytes, so one value filling half the sample is filler. + // The wide margin keeps a zero-padded header from making a real encrypted file look like filler. + var isFiller = mostCommonCount * 2 > sample.Length; + + // Only about 38% of ciphertext bytes happen to be printable, so a sample that is 90% printable is text, even with a byte-order mark or a few non-ASCII characters. + var isText = textCount * 10 >= sample.Length * 9; + + return !isFiller && !isText; + } } diff --git a/SentryDeck.Tests/EncryptedClipDetectorTests.cs b/SentryDeck.Tests/EncryptedClipDetectorTests.cs index 7945e46..31d81ea 100644 --- a/SentryDeck.Tests/EncryptedClipDetectorTests.cs +++ b/SentryDeck.Tests/EncryptedClipDetectorTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Text; namespace SentryDeck.Tests; @@ -57,6 +58,40 @@ public void MissingFile_IsNotEncrypted() EncryptedClipDetector.LooksEncrypted(Path.Combine(_root, "nope.mp4")).ShouldBeFalse(); } + [Theory] + [InlineData(0x00)] + [InlineData(0xFF)] + public void File_FilledWithOneByte_IsNotEncrypted(byte filler) + { + // A power cut can leave clusters allocated but never written, which read back as one repeated byte; blaming the car's encryption would send the user to the wrong setting. + var bytes = new byte[1024 * 1024]; + Array.Fill(bytes, filler); + var path = WriteFile("filled.mp4", bytes); + + EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse(); + } + + [Fact] + public void File_MostlyZeroWithStrayBytes_IsNotEncrypted() + { + // A partly written header followed by unwritten space is still damage, not an encrypted container. + var bytes = new byte[64 * 1024]; + TestMp4.EncryptedLookingBytes.AsSpan(0, 64).CopyTo(bytes); + var path = WriteFile("sparse.mp4", bytes); + + EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse(); + } + + [Fact] + public void File_PlainText_IsNotEncrypted() + { + // The byte-order mark keeps the test honest about text that isn't pure ASCII. + var text = string.Join("\r\n", Enumerable.Repeat("This file was renamed to .mp4 but holds notes, not video.", 50)); + var path = WriteFile("notes.mp4", [.. Encoding.UTF8.GetPreamble(), .. Encoding.UTF8.GetBytes(text)]); + + EncryptedClipDetector.LooksEncrypted(path).ShouldBeFalse(); + } + private CamClip ClipWithFrontFiles(params byte[][] frontFileContents) { var start = new DateTime(2026, 7, 9, 10, 0, 0); @@ -89,6 +124,15 @@ public void Clip_WithOnePlayableChunk_IsNotEncrypted() EncryptedClipDetector.LooksEncrypted(clip).ShouldBeFalse(); } + [Fact] + public void Clip_WithOnlyAZeroFilledFrontFile_IsNotEncrypted() + { + // A single-chunk clip hinges on one file, so a zero-filled front file must not get the whole clip blamed on the vehicle's encryption. + var clip = ClipWithFrontFiles(new byte[1024 * 1024]); + + EncryptedClipDetector.LooksEncrypted(clip).ShouldBeFalse(); + } + [Fact] public void Clip_WithNoChunks_IsNotEncrypted() { From 8479a36ad002f6319340402f772e8cc901afbcc6 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:30:10 -0500 Subject: [PATCH 04/73] Play clips whose folder was renamed to a very long name A clip in a folder renamed to a long name without a date (about 223 characters or more) would not play. It showed a raw "filename, directory name, or volume label syntax is incorrect" error that repeated the whole folder name and an internal temp path. Such a folder keeps its full name as the clip name, and FfconcatMediaSourceBuilder put that whole name into every camera's playlist file name. With the path hash and a camera suffix such as -right_repeater.ffconcat added, the file name passed the 255-character limit and writing the playlist failed. The clip name part of the playlist file name is now capped at 64 characters. The hash of the clip's folder path still keeps playlists of different clips apart, and date-named Tesla folders (19 characters) get the same file names as before. Found in v1.1.0 local testing: F16. --- .../Playback/FfconcatMediaSourceBuilder.cs | 9 +++++++++ .../FfconcatMediaSourceBuilderTests.cs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs index a672f1e..79ed949 100644 --- a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs +++ b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs @@ -14,6 +14,10 @@ public partial class FfconcatMediaSourceBuilder : IClipMediaSourceBuilder private static readonly string DefaultPlaylistDirectory = Path.Combine(Path.GetTempPath(), "SentryDeck", "playlists"); + // A folder renamed without a date keeps its whole name as the clip name, and a long one would push the playlist file names past the 255-character limit, so the clip could not open. + // The name only helps someone reading the playlist folder or the log; the path hash is what keeps clips apart, so capping it costs nothing. + private const int MaxClipNameLengthInFileName = 64; + /// /// Where the generated .ffconcat playlists are written. /// The app shares one directory and reuses each clip's file name across sessions, so the folder stays bounded; tests override it with a directory of their own because their fixture clips live under a fresh GUID root every run and would otherwise leave a permanent file behind for each one. @@ -180,6 +184,11 @@ private static string HashForFileName(string path) private static string SanitizeForFileName(string name) { var sanitized = InvalidFileNameCharsRegex().Replace(name ?? string.Empty, "_"); + if (sanitized.Length > MaxClipNameLengthInFileName) + { + sanitized = sanitized[..MaxClipNameLengthInFileName]; + } + return string.IsNullOrEmpty(sanitized) ? "clip" : sanitized; } diff --git a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs index a31347c..f43d809 100644 --- a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs +++ b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs @@ -154,6 +154,23 @@ public void Build_OverwritesPlaylistOnRebuild() secondContent.ShouldBe(firstContent); } + [Fact] + public void Build_ClipFolderRenamedToAVeryLongName_StillWritesEveryCamerasPlaylist() + { + // A folder renamed without a date keeps its whole name as the clip name, and one of a couple of hundred characters used to push the playlist file names past the 255-character limit, so the clip failed to open. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var longName = string.Concat(Enumerable.Repeat("Road trip along the coast ", 10)); + var clip = new CamClip(clipFiles.Clip.FullPath, longName, clipFiles.Clip.Timestamp, clipFiles.Clip.Chunks, camEvent: null); + + var mediaSource = Build(clip); + + mediaSource.CameraPlaylistPaths.Keys.ShouldBe(CameraNames.All, ignoreOrder: true); + foreach (var (camera, playlistPath) in mediaSource.CameraPlaylistPaths) + { + File.ReadAllText(playlistPath).ShouldContain(clipFiles.GetFfconcatPath(0, camera)); + } + } + [Fact] public void Build_TwoDifferentClipsWithTheSameName_WritePlaylistsToDifferentPaths() { From ce73b9934976eafa255ae7ece974f8c98fdb82e4 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:16:10 -0500 Subject: [PATCH 05/73] Keep clips whose event.json repeats a field An event.json with a repeated field (two "city" entries, say) next to a field the strict parser rejects (like Tesla's occasional blank est_lat) made the whole clip vanish from the list. The lenient fallback read the file through JsonObject, which throws on a repeated name, and CamClip.TryMap then skipped the folder with only a log warning. A field whose name or string value held a lone surrogate escape threw the same way. The fallback now reads the fields from a JsonDocument, which allows repeated names; the last one wins, as on the strict path, so which path runs can't change the value. A field name or string value that can't be decoded is passed over instead of throwing, so the clip keeps the fields that do parse. Falling back to the lenient path now logs the file and the offending field. Found in v1.1.0 local testing: F17. --- SentryDeck.Data/CamEvent.cs | 87 +++++++++++++------ .../CamDiscoveryResilienceTests.cs | 19 ++++ SentryDeck.Tests/CamEventTests.cs | 75 ++++++++++++++++ 3 files changed, 155 insertions(+), 26 deletions(-) diff --git a/SentryDeck.Data/CamEvent.cs b/SentryDeck.Data/CamEvent.cs index c193c79..9ccaaef 100644 --- a/SentryDeck.Data/CamEvent.cs +++ b/SentryDeck.Data/CamEvent.cs @@ -1,6 +1,5 @@ using System.Globalization; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Serilog; @@ -56,67 +55,103 @@ public record class CamEvent /// /// Deserializes event JSON and returns null for malformed payloads. /// - public static CamEvent Deserialize(string json) + public static CamEvent Deserialize(string json) => Deserialize(json, path: null); + + private static CamEvent Deserialize(string json, string path) { try { return JsonSerializer.Deserialize(json, JsonSerializerOptions); } - catch (JsonException) + catch (JsonException ex) { // A single malformed field (e.g. the blank est_lat Tesla sometimes writes) makes strict deserialization throw, which would discard ALL metadata for the clip -- losing the city and the timestamp the clip name falls back to. // Recover field by field instead, keeping whatever parses. + // The log names the file and the offending field, so a clip missing its city or date can be traced back to its event.json. + Log.Warning(ex, "Event metadata didn't parse cleanly; keeping whatever fields can be read. File={File}", path); return DeserializeLenient(json); } } private static CamEvent DeserializeLenient(string json) { - JsonNode root; + // JsonDocument rather than JsonNode: a JsonObject throws on a repeated property name, and that exception used to drop the whole clip from the library. + JsonDocument document; try { - root = JsonNode.Parse(json); + document = JsonDocument.Parse(json); } catch (JsonException) { return null; } - if (root is not JsonObject obj) + using (document) { - return null; - } + var obj = document.RootElement; + if (obj.ValueKind != JsonValueKind.Object) + { + return null; + } - return new CamEvent - { - Timestamp = ParseDateTime(ReadRaw(obj, "timestamp")), - City = ReadRaw(obj, "city"), - EstLat = ParseDecimal(ReadRaw(obj, "est_lat")), - EstLon = ParseDecimal(ReadRaw(obj, "est_lon")), - Reason = ReadRaw(obj, "reason"), - Camera = ParseInt(ReadRaw(obj, "camera")), - }; + return new CamEvent + { + Timestamp = ParseDateTime(ReadRaw(obj, "timestamp")), + City = ReadRaw(obj, "city"), + EstLat = ParseDecimal(ReadRaw(obj, "est_lat")), + EstLon = ParseDecimal(ReadRaw(obj, "est_lon")), + Reason = ReadRaw(obj, "reason"), + Camera = ParseInt(ReadRaw(obj, "camera")), + }; + } } // Case-insensitive lookup (mirroring PropertyNameCaseInsensitive on the strict path) returning the field as text: a JSON string yields its unquoted content, a number/bool its literal, so the typed parsers below accept both quoted and unquoted values like the strict path does. - private static string ReadRaw(JsonObject obj, string name) + // The last of several same-named fields wins, as it does on the strict path, so which path runs can't change the value read. + private static string ReadRaw(JsonElement obj, string name) { - JsonNode node = null; - foreach (var pair in obj) + JsonElement? match = null; + foreach (var property in obj.EnumerateObject()) { - if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + if (NameMatches(property, name)) { - node = pair.Value; - break; + match = property.Value; } } - if (node is null || node.GetValueKind() == JsonValueKind.Null) + if (match is not { } value || value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String) { + return value.GetRawText(); + } + + try + { + return value.GetString(); + } + catch (InvalidOperationException) + { + // An escaped lone surrogate is valid JSON syntax but can't become a .NET string; losing just this field keeps the rest of the metadata. return null; } + } - return node.GetValueKind() == JsonValueKind.String ? node.GetValue() : node.ToJsonString(); + // A property name can hold the same undecodable lone surrogate escape as a value, and reading it throws just the same. + // Such a name can't be one of the fields read here, so it is passed over instead of costing the clip every field that does parse. + private static bool NameMatches(JsonProperty property, string name) + { + try + { + return string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase); + } + catch (InvalidOperationException) + { + return false; + } } // RoundtripKind so this agrees with the strict System.Text.Json path above on a timestamp that carries a Z or an offset. @@ -150,6 +185,6 @@ public static CamEvent FromFile(string path) return null; } - return Deserialize(json); + return Deserialize(json, path); } } diff --git a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs index b2c5ae1..863197b 100644 --- a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs +++ b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs @@ -77,6 +77,25 @@ public void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp() clip.Timestamp.ShouldBe(new DateTime(2023, 8, 28, 13, 9, 35)); // earliest chunk, not MinValue } + [Fact] + public void FindClips_EventJsonWithADuplicateKeyAndABlankField_StillLoadsTheClip() + { + // The event metadata is optional, so a malformed event.json must never hide the playable footage beside it. + using var temp = new TempDirectory(); + + var dir = CreateSubDir(temp.Path, "2025-01-01_00-00-17"); + Touch(dir, "2025-01-01_00-00-17-front.mp4"); + File.WriteAllText( + Path.Combine(dir, "event.json"), + """{"timestamp":"2025-01-01T00:00:17","city":"DupA","city":"DupB","est_lat":""}"""); + + var clip = CamClip.FindClips(temp.Path).ShouldHaveSingleItem(); + + clip.FullPath.ShouldBe(dir); + clip.Event.ShouldNotBeNull(); + clip.Event.City.ShouldBe("DupB"); + } + [Fact] public void Map_CalendarInvalidFolderName_DoesNotThrowAndKeepsChunks() { diff --git a/SentryDeck.Tests/CamEventTests.cs b/SentryDeck.Tests/CamEventTests.cs index e4f0410..cdf662a 100644 --- a/SentryDeck.Tests/CamEventTests.cs +++ b/SentryDeck.Tests/CamEventTests.cs @@ -107,6 +107,81 @@ public void Deserialization_BlankCoordinateKeepsCityAndTimestamp() camEvent.Camera.ShouldBe(3); } + [Fact] + public void Deserialize_DuplicateKeyBesideABlankField_KeepsTheFieldsThatParse() + { + // The blank est_lat sends this payload down the lenient path, where a repeated name used to throw and take the whole clip out of the library. + var json = """ + { + "timestamp":"2025-01-01T00:00:17", + "city":"DupA", + "city":"DupB", + "est_lat":"", + "reason":"sentry_aware_object_detection" + } + """; + + var camEvent = CamEvent.Deserialize(json); + + camEvent.ShouldNotBeNull(); + camEvent.Timestamp.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 17)); + camEvent.City.ShouldBe("DupB"); + camEvent.Reason.ShouldBe("sentry_aware_object_detection"); + } + + [Theory] + [InlineData("""{"city":"A","city":"B"}""")] + [InlineData("""{"city":"A","City":"B"}""")] + public void Deserialize_DuplicateKey_ReadsTheSameOnBothPaths(string json) + { + // Which path runs depends on an unrelated field, so a repeated name must resolve to the same value either way. + var strict = CamEvent.Deserialize(json); + var lenient = CamEvent.Deserialize(json.Replace("}", ""","est_lat":""}""")); + + strict.ShouldNotBeNull(); + lenient.ShouldNotBeNull(); + lenient.City.ShouldBe(strict.City); + } + + [Fact] + public void Deserialize_StringFieldWithALoneSurrogateEscape_KeepsTheOtherFields() + { + // A lone surrogate is valid JSON syntax but can't be decoded into a string, so only that one field is lost. + var json = """ + { + "timestamp":"2025-01-01T00:00:17", + "city":"\uD800", + "reason":"sentry_aware_object_detection" + } + """; + + var camEvent = CamEvent.Deserialize(json); + + camEvent.ShouldNotBeNull(); + camEvent.City.ShouldBeNull(); + camEvent.Timestamp.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 17)); + camEvent.Reason.ShouldBe("sentry_aware_object_detection"); + } + + [Fact] + public void Deserialize_PropertyNameWithALoneSurrogateEscape_KeepsTheOtherFields() + { + // The undecodable name can't be a field Tesla writes, so it must not cost the clip the fields that are there. + var json = """ + { + "timestamp":"2025-01-01T00:00:17", + "\uD800":1, + "city":"Hutto" + } + """; + + var camEvent = CamEvent.Deserialize(json); + + camEvent.ShouldNotBeNull(); + camEvent.Timestamp.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 17)); + camEvent.City.ShouldBe("Hutto"); + } + [Fact] public void Deserialization_ReturnsNullForNonObjectJson() { From d90c849d29f39c0a2c8781da7f2dd18ff2580def Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:25:41 -0500 Subject: [PATCH 06/73] Find clips under hidden or system folders When a folder between the picked root and the clips (SavedClips, SentryClips) carried the Hidden or System attribute, which copy and repair tools sometimes leave behind, none of the clips under it appeared, and nothing in the app or the log said why. The scan used .NET's default enumeration options, which skip Hidden and System folders and never descend into them. The scan now walks hidden and system folders too. The Recycle Bin and System Volume Information are skipped by name instead, so picking a drive root still doesn't bring back clips the app's own Delete sent to the Recycle Bin. ACL-denied folders, such as the compatibility junctions Windows keeps in every profile, are still skipped as before. Found in v1.1.0 local testing: F35. --- SentryDeck.Data/CamClip.cs | 20 +++++++++- .../CamDiscoveryResilienceTests.cs | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/SentryDeck.Data/CamClip.cs b/SentryDeck.Data/CamClip.cs index cd87aca..da82c01 100644 --- a/SentryDeck.Data/CamClip.cs +++ b/SentryDeck.Data/CamClip.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.IO.Enumeration; using System.Text.RegularExpressions; using Serilog; @@ -123,17 +124,24 @@ private static IEnumerable EnumerateClipCandidates(string rootDirectory) { yield return rootDirectory; - // IgnoreInaccessible so one ACL-denied subfolder (e.g. System Volume Information at a drive root) is skipped rather than aborting the entire scan. + // IgnoreInaccessible so one ACL-denied subfolder is skipped rather than aborting the entire scan. + // It also keeps the scan out of the hidden "Application Data" style junctions Windows leaves in every profile: they deny listing, and some point back at their own parent folder. + // AttributesToSkip defaults to Hidden | System, which silently dropped every clip under a SavedClips or SentryClips folder that a copy or repair tool had marked that way. var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true, + AttributesToSkip = 0, }; IEnumerator directories; try { - directories = Directory.EnumerateDirectories(rootDirectory, "*", options).GetEnumerator(); + directories = new FileSystemEnumerable(rootDirectory, static (ref FileSystemEntry entry) => entry.ToSpecifiedFullPath(), options) + { + ShouldIncludePredicate = static (ref FileSystemEntry entry) => entry.IsDirectory && !IsHousekeepingFolder(entry.FileName), + ShouldRecursePredicate = static (ref FileSystemEntry entry) => !IsHousekeepingFolder(entry.FileName), + }.GetEnumerator(); } catch (Exception ex) { @@ -168,6 +176,14 @@ private static IEnumerable EnumerateClipCandidates(string rootDirectory) } } + /// + /// Windows keeps these folders at a drive root, and the Recycle Bin holds the clips the app's own Delete sent there, so scanning a drive root must not list them again as live clips. + /// Skipping them by attribute used to do this by accident; matching the name keeps it working now that hidden and system folders are scanned. + /// + private static bool IsHousekeepingFolder(ReadOnlySpan name) + => name.Equals("$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase) + || name.Equals("System Volume Information", StringComparison.OrdinalIgnoreCase); + [GeneratedRegex(@"(?\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})")] private static partial Regex FolderNameRegex(); } diff --git a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs index 863197b..aa7b599 100644 --- a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs +++ b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs @@ -60,6 +60,46 @@ public void FindClips_OneCalendarInvalidFileDoesNotDiscardOtherClips() clips.Count.ShouldBe(3); // the bad file is skipped; every real clip still loads } + [Theory] + [InlineData(FileAttributes.Hidden)] + [InlineData(FileAttributes.System)] + public void FindClips_ClipUnderAHiddenOrSystemSubfolder_IsFound(FileAttributes attribute) + { + // Copy and repair tools can leave these attributes on SavedClips or SentryClips, and the clips under them used to vanish without a word. + using var temp = new TempDirectory(); + + var savedClips = CreateSubDir(temp.Path, "SavedClips"); + var dir = CreateSubDir(savedClips, "2023-01-01_10-00-00"); + Touch(dir, "2023-01-01_10-00-00-front.mp4"); + File.SetAttributes(savedClips, File.GetAttributes(savedClips) | attribute); + + var clip = CamClip.FindClips(temp.Path).ShouldHaveSingleItem(); + + clip.FullPath.ShouldBe(dir); + } + + [Theory] + [InlineData("$RECYCLE.BIN")] + [InlineData("$Recycle.Bin")] + [InlineData("System Volume Information")] + public void FindClips_ClipInsideTheRecycleBinOrSystemVolumeInformation_IsNotListed(string folderName) + { + // Deleting a clip from the app sends it to the drive's Recycle Bin, so scanning a drive root must not bring it back as a live clip. + using var temp = new TempDirectory(); + + var live = CreateSubDir(temp.Path, "2023-01-01_10-00-00"); + Touch(live, "2023-01-01_10-00-00-front.mp4"); + + var bin = CreateSubDir(temp.Path, folderName); + var recycled = CreateSubDir(CreateSubDir(bin, "S-1-5-21-1000"), "$R1A2B3C"); + Touch(recycled, "2023-01-02_10-00-00-front.mp4"); + File.SetAttributes(bin, File.GetAttributes(bin) | FileAttributes.Hidden | FileAttributes.System); + + var clip = CamClip.FindClips(temp.Path).ShouldHaveSingleItem(); + + clip.FullPath.ShouldBe(live); + } + [Fact] public void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp() { From 9054c145b765b04e3c37e590a7c067eff9ae5635 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:37:53 -0500 Subject: [PATCH 07/73] Scan large clip libraries without holding every folder open Picking a folder with thousands of clip folders (a full SavedClips or SentryClips) made the scan hold an open handle to every one of them at once: about 500 extra handles for 500 clips, growing with the width of the library. .NET's recursive directory enumeration walks breadth-first and opens a handle to each subfolder when it queues it, closing it only once it gets around to reading that folder. The scan now walks the tree depth-first, reading one folder's subfolders at a time and closing it before going deeper, so the walk holds at most one directory handle however wide the library is. A folder that can't be listed, such as a corrupt directory on the car's USB drive, now costs only what is beneath it instead of ending the scan, because depth-first order reaches each event folder before its later siblings. Hidden, system and ACL-denied folders, the Recycle Bin, and a folder deleted mid-scan are handled as before. Found in v1.1.0 local testing: F30. --- SentryDeck.Data/CamClip.cs | 90 ++++++++++--------- SentryDeck.Tests/CamDiscoveryHandleTests.cs | 62 +++++++++++++ .../CamDiscoveryResilienceTests.cs | 58 ++++++++++++ 3 files changed, 166 insertions(+), 44 deletions(-) create mode 100644 SentryDeck.Tests/CamDiscoveryHandleTests.cs diff --git a/SentryDeck.Data/CamClip.cs b/SentryDeck.Data/CamClip.cs index da82c01..f461a25 100644 --- a/SentryDeck.Data/CamClip.cs +++ b/SentryDeck.Data/CamClip.cs @@ -120,62 +120,64 @@ private static CamClip TryMap(string directory) public override string ToString() => $"{Name}"; + /// + /// Walks the tree depth-first, reading one folder's subfolders at a time and closing it before going further. + /// The built-in recursive enumeration went breadth-first and opened a handle to every subfolder it queued, so a SavedClips folder with thousands of events held thousands of handles at once. + /// private static IEnumerable EnumerateClipCandidates(string rootDirectory) { - yield return rootDirectory; + var pending = new Stack(); + pending.Push(rootDirectory); - // IgnoreInaccessible so one ACL-denied subfolder is skipped rather than aborting the entire scan. - // It also keeps the scan out of the hidden "Application Data" style junctions Windows leaves in every profile: they deny listing, and some point back at their own parent folder. - // AttributesToSkip defaults to Hidden | System, which silently dropped every clip under a SavedClips or SentryClips folder that a copy or repair tool had marked that way. - var options = new EnumerationOptions + while (pending.TryPop(out var directory)) { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = 0, - }; + yield return directory; - IEnumerator directories; - try - { - directories = new FileSystemEnumerable(rootDirectory, static (ref FileSystemEntry entry) => entry.ToSpecifiedFullPath(), options) + List subfolders; + try { - ShouldIncludePredicate = static (ref FileSystemEntry entry) => entry.IsDirectory && !IsHousekeepingFolder(entry.FileName), - ShouldRecursePredicate = static (ref FileSystemEntry entry) => !IsHousekeepingFolder(entry.FileName), - }.GetEnumerator(); - } - catch (Exception ex) - { - Log.Warning(ex, "Could not enumerate clip folders. Root={Root}", rootDirectory); - yield break; - } + subfolders = ListSubfolders(directory); + } + catch (DirectoryNotFoundException) when (directory != rootDirectory) + { + // Deleted or moved since its parent was read, for example a clip removed in Explorer mid-scan. + // The built-in recursion skipped these too, and the folders still to come are worth scanning. + continue; + } + catch (Exception ex) + { + // Any other failure (a corrupt directory on the car's USB drive, a folder swapped for a file, a junction loop) costs only what is beneath this folder. + // Depth-first order lists each event folder before reaching its later siblings, so ending the walk here would hide most of a SavedClips folder, not just the few folders the old breadth-first scan lost. + Log.Warning(ex, "Could not list a clip folder's subfolders; skipping them. Root={Root} Folder={Folder}", rootDirectory, directory); + continue; + } - using (directories) - { - while (true) + // Reversed so they come off the stack in the order the file system listed them. + for (var i = subfolders.Count - 1; i >= 0; i--) { - string directory; - try - { - if (!directories.MoveNext()) - { - break; - } - - directory = directories.Current; - } - catch (Exception ex) - { - // A transient IO error partway through recursion (bad sector, a drive yanked mid-scan, a junction loop) would otherwise abort the whole root and discard every clip found so far. - // Stop here and keep what we already enumerated. - Log.Warning(ex, "Clip-folder enumeration stopped early; keeping folders found so far. Root={Root}", rootDirectory); - break; - } - - yield return directory; + pending.Push(subfolders[i]); } } } + // IgnoreInaccessible so one ACL-denied subfolder is skipped rather than aborting the entire scan. + // It also keeps the scan out of the hidden "Application Data" style junctions Windows leaves in every profile: they deny listing, and some point back at their own parent folder. + // AttributesToSkip defaults to Hidden | System, which silently dropped every clip under a SavedClips or SentryClips folder that a copy or repair tool had marked that way. + private static readonly EnumerationOptions SubfolderListingOptions = new() + { + IgnoreInaccessible = true, + AttributesToSkip = 0, + }; + + /// + /// Reads the whole listing before returning, so the folder's handle is closed before the walk goes deeper instead of staying open for every level above the current one. + /// + private static List ListSubfolders(string directory) + => new FileSystemEnumerable(directory, static (ref FileSystemEntry entry) => entry.ToSpecifiedFullPath(), SubfolderListingOptions) + { + ShouldIncludePredicate = static (ref FileSystemEntry entry) => entry.IsDirectory && !IsHousekeepingFolder(entry.FileName), + }.ToList(); + /// /// Windows keeps these folders at a drive root, and the Recycle Bin holds the clips the app's own Delete sent there, so scanning a drive root must not list them again as live clips. /// Skipping them by attribute used to do this by accident; matching the name keeps it working now that hidden and system folders are scanned. diff --git a/SentryDeck.Tests/CamDiscoveryHandleTests.cs b/SentryDeck.Tests/CamDiscoveryHandleTests.cs new file mode 100644 index 0000000..2012e5e --- /dev/null +++ b/SentryDeck.Tests/CamDiscoveryHandleTests.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO; + +namespace SentryDeck.Tests; + +/// +/// Tests that count the whole process's open handles run on their own. +/// Tests running in parallel open and close enough handles to hide the hundreds a regression would hold. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ProcessHandleCountCollection +{ + public const string Name = "Process handle count"; +} + +[Collection(ProcessHandleCountCollection.Name)] +public sealed class CamDiscoveryHandleTests +{ + [Fact] + public void FindClips_FolderWithManyClips_DoesNotHoldAHandleOpenPerSubfolder() + { + // A SavedClips or SentryClips folder can hold thousands of events. + // The scan used to open a handle to every one of them before reading any, so its open handles grew with the library instead of staying flat. + const int ClipCount = 500; + using var temp = new TempDirectory(); + + var savedClips = temp.CreateSubdirectory("SavedClips"); + for (var i = 0; i < ClipCount; i++) + { + var name = new DateTime(2023, 1, 1).AddMinutes(i).ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); + var clip = Directory.CreateDirectory(Path.Combine(savedClips, name)).FullName; + File.WriteAllBytes(Path.Combine(clip, $"{name}-front.mp4"), []); + } + + // Earlier tests can leave handles waiting on finalizers, and closing them mid-scan would offset the count. + GC.Collect(); + GC.WaitForPendingFinalizers(); + + var baseline = OpenHandleCount(); + var peak = baseline; + var found = 0; + foreach (var _ in CamClip.FindClips(temp.Path)) + { + if (++found % 50 == 0) + { + peak = Math.Max(peak, OpenHandleCount()); + } + } + + found.ShouldBe(ClipCount); + + // One handle per clip folder reaches the full clip count; the bound leaves room for handles the runtime opens in the background. + (peak - baseline).ShouldBeLessThan(ClipCount / 5); + } + + private static int OpenHandleCount() + { + using var process = Process.GetCurrentProcess(); + return process.HandleCount; + } +} diff --git a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs index aa7b599..5e6d50b 100644 --- a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs +++ b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs @@ -100,6 +100,64 @@ public void FindClips_ClipInsideTheRecycleBinOrSystemVolumeInformation_IsNotList clip.FullPath.ShouldBe(live); } + [Fact] + public void FindClips_FolderDeletedMidScan_StillFindsTheFoldersAfterIt() + { + // Deleting a clip in Explorer while the app scans must not cut the scan short and hide the clips listed after it. + using var temp = new TempDirectory(); + + var first = CreateSubDir(temp.Path, "2023-01-01_10-00-00"); + Touch(first, "2023-01-01_10-00-00-front.mp4"); + + var deleted = CreateSubDir(temp.Path, "2023-01-01_11-00-00"); + Touch(deleted, "2023-01-01_11-00-00-front.mp4"); + + var last = CreateSubDir(temp.Path, "2023-01-01_12-00-00"); + Touch(last, "2023-01-01_12-00-00-front.mp4"); + + var found = new List(); + foreach (var clip in CamClip.FindClips(temp.Path)) + { + found.Add(clip.FullPath); + if (found.Count == 1) + { + Directory.Delete(deleted, recursive: true); + } + } + + found.ShouldBe([first, last], ignoreOrder: true); + } + + [Fact] + public void FindClips_FolderThatCanNoLongerBeListed_StillFindsTheFoldersAfterIt() + { + // The scan lists each event folder before it reaches the ones after it, so one folder it can't list must not hide the rest of the library. + // A folder swapped for a file mid-scan stands in for a corrupt directory, which a test can't make on demand. + using var temp = new TempDirectory(); + + var first = CreateSubDir(temp.Path, "2023-01-01_10-00-00"); + Touch(first, "2023-01-01_10-00-00-front.mp4"); + + var broken = CreateSubDir(temp.Path, "2023-01-01_11-00-00"); + Touch(broken, "2023-01-01_11-00-00-front.mp4"); + + var last = CreateSubDir(temp.Path, "2023-01-01_12-00-00"); + Touch(last, "2023-01-01_12-00-00-front.mp4"); + + var found = new List(); + foreach (var clip in CamClip.FindClips(temp.Path)) + { + found.Add(clip.FullPath); + if (found.Count == 1) + { + Directory.Delete(broken, recursive: true); + File.WriteAllBytes(broken, []); + } + } + + found.ShouldBe([first, last], ignoreOrder: true); + } + [Fact] public void Map_DateLessFolderWithoutEvent_FallsBackToFirstChunkTimestamp() { From 09ed5fb994fe981a1c03aa2abe58086259afc264 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:42:15 -0500 Subject: [PATCH 08/73] Show each clip's real length in the list Most rows in the clip list read a minute longer than the player then showed: a standard ten-minute save said "~11 min" against a 9:28 to 10:56 timeline, and a 10 s clip said "~1 min". The list counted every chunk as a full minute, but Tesla almost always cuts the last chunk short when recording stops, so the estimate always ran high, and a clip with a gap in its recording read several minutes too long. The scan now reads the front-camera length from the MP4 header of the last chunk, and of any chunk whose next one doesn't start about a minute later (a gap in the recording), and counts the other chunks as full minutes. File-name spacing alone can't bound a chunk, because the car's clock can jump. A chunk with no readable length still counts as a minute, and a header claiming more than a minute is capped there, so a corrupt file can't inflate the estimate. A clip shorter than half a minute reads "<1 min" instead of rounding to "~1 min" or the no-footage dash. Found in v1.1.0 local testing: F84, F04, F36, F123. --- SentryDeck.Data/CamClip.cs | 51 +++++++++++++- SentryDeck.Tests/CamStorageTests.cs | 100 ++++++++++++++++++++++++++++ SentryDeck.Tests/ConverterTests.cs | 26 +++++++- SentryDeck/ClipConverters.cs | 12 ++-- 4 files changed, 183 insertions(+), 6 deletions(-) diff --git a/SentryDeck.Data/CamClip.cs b/SentryDeck.Data/CamClip.cs index f461a25..262d1db 100644 --- a/SentryDeck.Data/CamClip.cs +++ b/SentryDeck.Data/CamClip.cs @@ -40,6 +40,12 @@ public partial record class CamClip /// public string ThumbnailPath { get; private init; } + /// + /// Rough length of the footage for the clip list, known before any media is opened. + /// A scanned clip counts its final chunk, and any chunk whose successor doesn't start about a minute later, at its recorded length; otherwise every chunk counts as . + /// + public TimeSpan EstimatedDuration { get; private init; } + public CamClip(string path, string name, DateTime timestamp, IEnumerable chunks, CamEvent camEvent) { FullPath = Path.GetFullPath(path); @@ -48,6 +54,7 @@ public CamClip(string path, string name, DateTime timestamp, IEnumerable @@ -84,7 +91,49 @@ public static CamClip Map(string directory) timestamp = chunks[0].Timestamp; } - return new(directory, title, timestamp, chunks, eventData); + return new(directory, title, timestamp, chunks, eventData) + { + EstimatedDuration = EstimateDuration(chunks), + }; + } + + /// + /// Tesla almost always cuts a chunk short when recording stops, so counting it as a full minute made rows in the list read a minute or more longer than the player. + /// That happens to the last chunk and to chunks around a gap in the recording, and a next chunk that doesn't start about a minute later is the only sign of a gap the file names give. + /// Reading only those chunks' headers keeps the scan to a few reads per clip, since the other chunks are nearly always full minutes. + /// The spacing alone can't stand in for a chunk's length: the car's clock can jump, so a full chunk can be followed by the next one a few seconds later. + /// + private static TimeSpan EstimateDuration(IReadOnlyList chunks) + { + var nominalChunk = TimeSpan.FromSeconds(ClipTimeline.EstimatedChunkSeconds); + var total = TimeSpan.Zero; + + for (var i = 0; i < chunks.Count; i++) + { + var ranFullMinute = i + 1 < chunks.Count + && (chunks[i + 1].Timestamp - chunks[i].Timestamp - nominalChunk).Duration() <= RegularChunkSpacingTolerance; + total += ranFullMinute ? nominalChunk : RecordedLengthOrNominal(chunks[i], nominalChunk); + } + + return total; + } + + /// + /// Consecutive full chunks start 60 to 62 s apart on real drives, so a next chunk within a couple of seconds of a minute later means this one ran its full length. + /// + private static readonly TimeSpan RegularChunkSpacingTolerance = TimeSpan.FromSeconds(2); + + private static TimeSpan RecordedLengthOrNominal(CamChunk chunk, TimeSpan nominalChunk) + { + if (chunk.Files.TryGetValue(CameraNames.Front, out var front) + && Mp4DurationReader.TryReadDuration(front.FullPath) is { } recorded + && recorded > TimeSpan.Zero) + { + // Capped at the nominal length so a corrupt header claiming hours can only shorten the estimate, never inflate it. + return recorded < nominalChunk ? recorded : nominalChunk; + } + + return nominalChunk; } /// diff --git a/SentryDeck.Tests/CamStorageTests.cs b/SentryDeck.Tests/CamStorageTests.cs index f3d5ae2..5bf4b57 100644 --- a/SentryDeck.Tests/CamStorageTests.cs +++ b/SentryDeck.Tests/CamStorageTests.cs @@ -1,3 +1,5 @@ +using System.IO; + namespace SentryDeck.Tests; public sealed class CamStorageTests @@ -59,6 +61,104 @@ public void ChunksAreInCorrectOrder() chunks.Select(chunk => chunk.Timestamp).ShouldBeInOrder(); } + [Fact] + public void Map_PartialFinalChunk_CountsItAtItsRecordedLength() + { + using var files = TestClipFiles.Create(3, chunkDurations: [TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(10)]); + + var clip = CamClip.Map(files.RootPath); + + clip.EstimatedDuration.ShouldBe(TimeSpan.FromSeconds(130)); + } + + [Fact] + public void Map_FinalChunkWithoutAReadableDuration_CountsItAsAFullMinute() + { + // A truncated last chunk tells us nothing about its length, so the estimate keeps the nominal minute rather than guessing short. + using var files = TestClipFiles.Create(3, chunkDurations: [TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(10)]); + File.WriteAllBytes(files.GetPath(2, CameraNames.Front), TestMp4.GarbageBytes); + + var clip = CamClip.Map(files.RootPath); + + clip.EstimatedDuration.ShouldBe(TimeSpan.FromMinutes(3)); + } + + [Fact] + public void Map_FinalChunkHeaderClaimingHours_CountsItAsAFullMinute() + { + using var files = TestClipFiles.Create(2, chunkDurations: [TimeSpan.FromSeconds(60), TimeSpan.FromHours(5)]); + + var clip = CamClip.Map(files.RootPath); + + clip.EstimatedDuration.ShouldBe(TimeSpan.FromMinutes(2)); + } + + [Fact] + public void Map_ShortChunksAtARecordingGap_CountsThemAtTheirRecordedLength() + { + // Laid out like a real clip the list showed as "~3 min" while the player showed 1:40: recording stopped after 11 s, resumed almost five minutes later, and the middle chunk ran 36 s. + using var folder = new TempDirectory(); + WriteFrontChunks(folder, (0, 11.0), (294, 35.8), (330, 53.4)); + + var clip = CamClip.Map(folder.Path); + + clip.EstimatedDuration.TotalSeconds.ShouldBe(100.2, tolerance: 0.01); + } + + [Fact] + public void Map_ShortChunkFollowedSecondsLater_CountsItAtItsRecordedLength() + { + // Laid out like a real clip the list showed as "~6 min" while the player showed 5:08: a 32 s chunk, a gap, then an 8 s chunk whose successor starts 5 s after it. + using var folder = new TempDirectory(); + WriteFrontChunks(folder, (0, 32.1), (210, 8.4), (215, 59.6), (275, 59.3), (336, 29.4)); + + var clip = CamClip.Map(folder.Path); + + clip.EstimatedDuration.TotalSeconds.ShouldBe(32.1 + 8.4 + 60 + 60 + 29.4, tolerance: 0.01); + } + + [Fact] + public void Map_FullChunkFollowedSecondsLater_CountsItAtItsRecordedLength() + { + // The next chunk's start can't bound a chunk's length on its own: the car's clock can jump, so a full chunk can be followed by the next one 4 s later. + using var folder = new TempDirectory(); + WriteFrontChunks(folder, (0, 59.8), (117, 59.5), (121, 59.5), (182, 9.6)); + + var clip = CamClip.Map(folder.Path); + + clip.EstimatedDuration.TotalSeconds.ShouldBe(59.8 + 59.5 + 60 + 9.6, tolerance: 0.01); + } + + [Fact] + public void Map_UnreadableOrImplausibleChunkAtAGap_CountsItAsAFullMinute() + { + // As for the last chunk, a header that can't be trusted may only leave the estimate where it was, never shrink or stretch it. + using var folder = new TempDirectory(); + WriteFrontChunks(folder, (0, 60), (200, 60), (400, 60), (600, 10)); + File.WriteAllBytes(FrontChunkPath(folder, 0), TestMp4.GarbageBytes); + File.WriteAllBytes(FrontChunkPath(folder, 200), TestMp4.BuildWithDuration(TimeSpan.FromHours(5))); + + var clip = CamClip.Map(folder.Path); + + clip.EstimatedDuration.ShouldBe(TimeSpan.FromSeconds(190)); + } + + private static readonly DateTime FirstChunkStart = new(2025, 8, 8, 13, 23, 45); + + /// + /// Writes one front file per chunk, named for a start the given number of seconds after the first chunk's, with a header recording the given length. + /// + private static void WriteFrontChunks(TempDirectory folder, params (int StartSeconds, double DurationSeconds)[] chunks) + { + foreach (var (startSeconds, durationSeconds) in chunks) + { + File.WriteAllBytes(FrontChunkPath(folder, startSeconds), TestMp4.BuildWithDuration(TimeSpan.FromSeconds(durationSeconds))); + } + } + + private static string FrontChunkPath(TempDirectory folder, int startSeconds) => + Path.Combine(folder.Path, $"{FirstChunkStart.AddSeconds(startSeconds):yyyy-MM-dd_HH-mm-ss}-{CameraNames.Front}.mp4"); + [Fact] public void MapRoot_WhenRootIsClipFolder_ReturnsThatClip() { diff --git a/SentryDeck.Tests/ConverterTests.cs b/SentryDeck.Tests/ConverterTests.cs index fab640b..4742a9e 100644 --- a/SentryDeck.Tests/ConverterTests.cs +++ b/SentryDeck.Tests/ConverterTests.cs @@ -172,12 +172,36 @@ public void DayGroupHeaderConverter_OlderDays_GetDistinctAbsoluteHeaders() [InlineData(95, "~1h 35m")] public void ClipDurationConverter_RendersTheModeledChunkDuration(int chunkCount, string expected) { - // Every chunk models 60s (ClipTimeline.EstimatedChunkSeconds), so the chunk count is the estimate in minutes; a clip whose chunks were all filtered out has nothing to estimate. + // A clip built without a scan has no measured final chunk, so every chunk models 60s and the chunk count is the estimate in minutes. + // A clip whose chunks were all filtered out has nothing to estimate. var result = new ClipDurationConverter().Convert(ClipWithChunks(chunkCount), typeof(string), null, null); result.ShouldBe(expected); } + [Fact] + public void ClipDurationConverter_ScannedClipEndingInAPartialChunk_ReadsTheRecordedLength() + { + // Ten full minutes plus the short tail Tesla writes when recording stops: the player shows about 10:08, so the list must not say "~11 min". + var durations = Enumerable.Repeat(TimeSpan.FromSeconds(60), 10).Append(TimeSpan.FromSeconds(8.5)).ToList(); + using var files = TestClipFiles.Create(durations.Count, chunkDurations: durations); + + var result = new ClipDurationConverter().Convert(CamClip.Map(files.RootPath), typeof(string), null, null); + + result.ShouldBe("~10 min"); + } + + [Fact] + public void ClipDurationConverter_ScannedClipShorterThanHalfAMinute_ReadsLessThanAMinute() + { + // A 10s clip used to read "~1 min"; rounding it down to "0 min" or the no-footage dash would be just as wrong. + using var files = TestClipFiles.Create(1, chunkDurations: [TimeSpan.FromSeconds(10)]); + + var result = new ClipDurationConverter().Convert(CamClip.Map(files.RootPath), typeof(string), null, null); + + result.ShouldBe("<1 min"); + } + [Fact] public void ClipDurationConverter_NonClipValues_RenderNothing() { diff --git a/SentryDeck/ClipConverters.cs b/SentryDeck/ClipConverters.cs index 5e6ecbf..bc2cd94 100644 --- a/SentryDeck/ClipConverters.cs +++ b/SentryDeck/ClipConverters.cs @@ -106,7 +106,7 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu } /// -/// Estimated clip duration as a human string, e.g. "~5 min" (uses the modeled = chunk count × 60s). +/// Estimated clip duration as a human string, e.g. "~5 min" (from ). /// public sealed class ClipDurationConverter : MarkupExtension, IValueConverter { @@ -117,11 +117,15 @@ public object Convert(object value, Type targetType, object parameter, CultureIn if (value is not CamClip clip) return string.Empty; - var duration = new ClipTimeline(clip.Chunks).Duration; - var minutes = (int)Math.Round(duration.TotalMinutes); - if (minutes <= 0) + var duration = clip.EstimatedDuration; + if (duration <= TimeSpan.Zero) return "—"; + // A clip of a few seconds has footage, so it must not round down to the dash that means there is none. + var minutes = (int)Math.Round(duration.TotalMinutes); + if (minutes == 0) + return "<1 min"; + return minutes < 60 ? $"~{minutes} min" : $"~{minutes / 60}h {minutes % 60}m"; From 43380d3df50437c862a5e2dc5bd2b7f2d271d02d Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:49:17 -0500 Subject: [PATCH 09/73] Label saved and Sentry clips without event data by their folder A clip in SavedClips or SentryClips with no event.json (footage from firmware that didn't write one), or with a blank reason, was listed as "Recent" with the grey stripe, and searching "Recent" returned it. The label came from the event reason alone and a missing reason always meant Recent, so the TeslaCam folder the clip sits in was never consulted. Each clip now records which TeslaCam folder it sits in (RecentClips, SavedClips or SentryClips, from its own folder name or its parent's), and a missing or blank reason falls back to it, so these clips read "Saved" or "Sentry" with that stripe color. The list row, search and the seek bar's event marker (its color and tooltip) use the same label. Clips with a reason keep their reason label, and clips outside Tesla's folders still read "Recent". Found in v1.1.0 local testing: F85. --- SentryDeck.Data/CamClip.cs | 26 ++++++++++++++ SentryDeck.Data/CamSourceFolder.cs | 19 +++++++++++ SentryDeck.Tests/CamStorageTests.cs | 15 ++++++++ SentryDeck.Tests/ClipDisplayTests.cs | 34 ++++++++++++++++++- SentryDeck.Tests/ConverterTests.cs | 10 ++++++ .../MainWindowViewModelTests.Clips.cs | 19 +++++++++++ .../MainWindowViewModelTests.Markers.cs | 14 ++++++++ SentryDeck/ClipConverters.cs | 9 ++--- SentryDeck/ClipDisplay.cs | 26 ++++++++++++-- SentryDeck/MainWindow.xaml | 18 +++++----- SentryDeck/ViewModels/ClipLibraryViewModel.cs | 2 +- SentryDeck/ViewModels/PlaybackViewModel.cs | 7 ++-- 12 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 SentryDeck.Data/CamSourceFolder.cs diff --git a/SentryDeck.Data/CamClip.cs b/SentryDeck.Data/CamClip.cs index 262d1db..fc2fb2b 100644 --- a/SentryDeck.Data/CamClip.cs +++ b/SentryDeck.Data/CamClip.cs @@ -46,6 +46,12 @@ public partial record class CamClip /// public TimeSpan EstimatedDuration { get; private init; } + /// + /// The TeslaCam folder the clip sits in, read from its path. + /// Firmware that wrote no event.json leaves no reason, so this is the only sign whether the clip was saved, kept by Sentry Mode, or is the rolling RecentClips buffer. + /// + public CamSourceFolder SourceFolder { get; private init; } + public CamClip(string path, string name, DateTime timestamp, IEnumerable chunks, CamEvent camEvent) { FullPath = Path.GetFullPath(path); @@ -55,6 +61,7 @@ public CamClip(string path, string name, DateTime timestamp, IEnumerable @@ -136,6 +143,25 @@ private static TimeSpan RecordedLengthOrNominal(CamChunk chunk, TimeSpan nominal return nominalChunk; } + /// + /// Tesla keeps RecentClips chunks loose in the folder itself but puts each saved or Sentry event in its own subfolder, so the clip folder's name is checked before its parent's. + /// + private static CamSourceFolder SourceFolderOf(string fullPath) + { + var folder = Path.TrimEndingDirectorySeparator(fullPath); + var own = SourceFolderNamed(Path.GetFileName(folder)); + return own != CamSourceFolder.Other ? own : SourceFolderNamed(Path.GetFileName(Path.GetDirectoryName(folder))); + } + + // Case-insensitive because copies made on other systems or by other tools don't always keep Tesla's capitalization. + private static CamSourceFolder SourceFolderNamed(string name) => name?.ToUpperInvariant() switch + { + "RECENTCLIPS" => CamSourceFolder.RecentClips, + "SAVEDCLIPS" => CamSourceFolder.SavedClips, + "SENTRYCLIPS" => CamSourceFolder.SentryClips, + _ => CamSourceFolder.Other, + }; + /// /// Finds clip folders inside the specified root directory. /// diff --git a/SentryDeck.Data/CamSourceFolder.cs b/SentryDeck.Data/CamSourceFolder.cs new file mode 100644 index 0000000..3d905e9 --- /dev/null +++ b/SentryDeck.Data/CamSourceFolder.cs @@ -0,0 +1,19 @@ +namespace SentryDeck; + +/// +/// The TeslaCam folder a clip was recorded into. +/// +public enum CamSourceFolder +{ + /// Not inside one of Tesla's folders, for example a clip copied somewhere else. + Other, + + /// The rolling dashcam buffer the car keeps overwriting. + RecentClips, + + /// Dashcam clips kept by a save, honk, or other trigger. + SavedClips, + + /// Clips recorded by Sentry Mode. + SentryClips, +} diff --git a/SentryDeck.Tests/CamStorageTests.cs b/SentryDeck.Tests/CamStorageTests.cs index 5bf4b57..ceab238 100644 --- a/SentryDeck.Tests/CamStorageTests.cs +++ b/SentryDeck.Tests/CamStorageTests.cs @@ -31,6 +31,21 @@ public void Map_ClipName_ComesFromFolderNameOrTimestamp(string path, string expe clip.Name.ShouldBe(expectedName); } + [Theory] + [InlineData(@"D:\TeslaCam\SavedClips\2024-05-02_16-49-35", CamSourceFolder.SavedClips)] + [InlineData(@"D:\TeslaCam\SentryClips\2024-05-02_16-49-35\", CamSourceFolder.SentryClips)] + [InlineData(@"D:\TeslaCam\RecentClips", CamSourceFolder.RecentClips)] + [InlineData(@"D:\TeslaCam\RecentClips\2024-05-02", CamSourceFolder.RecentClips)] + [InlineData(@"E:\teslacam\savedclips\2024-05-02_16-49-35", CamSourceFolder.SavedClips)] + [InlineData(@"D:\Backup\2024-05-02_16-49-35", CamSourceFolder.Other)] + [InlineData(@"D:\", CamSourceFolder.Other)] + public void Constructor_ClipPath_RecordsTheTeslaCamFolderItSitsIn(string path, CamSourceFolder expected) + { + var clip = new CamClip(path, "Clip", new DateTime(2024, 5, 2), [], camEvent: null); + + clip.SourceFolder.ShouldBe(expected); + } + [Fact] public void MapClipWithNonstandardNameFallsBackToEventDataForTimestamp() { diff --git a/SentryDeck.Tests/ClipDisplayTests.cs b/SentryDeck.Tests/ClipDisplayTests.cs index a2b741c..f3bd4ce 100644 --- a/SentryDeck.Tests/ClipDisplayTests.cs +++ b/SentryDeck.Tests/ClipDisplayTests.cs @@ -16,7 +16,39 @@ public void ReasonLabel_MapsRealTeslaReasons(string reason, string expected) [Fact] public void ReasonLabel_NullEvent_IsRecent() { - ClipDisplay.ReasonLabel(null).ShouldBe("Recent"); + ClipDisplay.ReasonLabel((CamEvent)null).ShouldBe("Recent"); + } + + [Theory] + [InlineData(@"D:\TeslaCam\SavedClips\2024-05-02_16-49-35", "Saved")] + [InlineData(@"D:\TeslaCam\SentryClips\2024-05-02_16-49-35", "Sentry")] + [InlineData(@"D:\TeslaCam\RecentClips", "Recent")] + [InlineData(@"D:\Backup\2024-05-02_16-49-35", "Recent")] + public void ReasonLabel_ClipWithoutEventJson_FollowsItsTeslaCamFolder(string path, string expected) + { + var clip = new CamClip(path, "Clip", new DateTime(2024, 5, 2), [], camEvent: null); + + ClipDisplay.ReasonLabel(clip).ShouldBe(expected); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ReasonKey_SavedClipWithBlankReason_IsSaved(string reason) + { + var clip = new CamClip(@"D:\TeslaCam\SavedClips\2024-05-02_16-49-35", "Clip", new DateTime(2024, 5, 2), [], new CamEvent { Reason = reason }); + + ClipDisplay.ReasonKey(clip).ShouldBe(ClipDisplay.ReasonSaved); + ClipDisplay.ReasonLabel(clip).ShouldBe("Saved"); + } + + [Fact] + public void ReasonLabel_ClipWithAReason_PrefersTheReasonOverItsFolder() + { + // Honks are filed under SavedClips, and the reason says more than the folder does. + var clip = new CamClip(@"D:\TeslaCam\SavedClips\2024-05-02_16-49-35", "Clip", new DateTime(2024, 5, 2), [], new CamEvent { Reason = "user_interaction_honk" }); + + ClipDisplay.ReasonLabel(clip).ShouldBe("Honk"); } [Theory] diff --git a/SentryDeck.Tests/ConverterTests.cs b/SentryDeck.Tests/ConverterTests.cs index 4742a9e..3758894 100644 --- a/SentryDeck.Tests/ConverterTests.cs +++ b/SentryDeck.Tests/ConverterTests.cs @@ -266,6 +266,16 @@ public void EventConverters_NonEventValues_FallBackToTheNoEventDefaults() new MapAvailabilityConverter().Convert("not an event", typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed); } + [Fact] + public void ReasonConverters_SentryClipWithoutEventJson_UseItsFolder() + { + // The clip row binds the clip itself, because a missing event.json leaves only the folder to say how the clip was recorded. + var clip = new CamClip(@"D:\TeslaCam\SentryClips\2024-05-02_16-49-35", "Sentry Without Event", Moment, [], camEvent: null); + + new ReasonLabelConverter().Convert(clip, typeof(string), null, null).ShouldBe("Sentry"); + new ReasonKeyConverter().Convert(clip, typeof(string), null, null).ShouldBe(ClipDisplay.ReasonSentry); + } + private static CamClip ClipWithChunks(int chunkCount) { var chunks = Enumerable.Range(0, chunkCount) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index d0ee1cd..83b5e76 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -76,6 +76,25 @@ public async Task FilteredClips_FiltersByFriendlyReason() vm.Library.FilteredClips.Single().Name.ShouldBe("Saver"); } + [Fact] + public async Task FilteredClips_SavedClipWithoutEventJson_IsFoundAsSavedNotRecent() + { + // Older firmware wrote no event.json, so the SavedClips folder is the only sign the clip was saved rather than left in the rolling buffer. + var clips = new List + { + new(@"D:\TeslaCam\SavedClips\2024-05-02_16-49-35", "Saved Without Event", new DateTime(2024, 5, 2), [], camEvent: null), + new(@"D:\TeslaCam\RecentClips", "Rolling Buffer", new DateTime(2024, 5, 3), [], camEvent: null), + }; + var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); + await vm.Library.LoadClipsAsync(new[] { "root" }); + + vm.Library.FilterText = "recent"; + vm.Library.FilteredClips.Single().Name.ShouldBe("Rolling Buffer"); + + vm.Library.FilterText = "saved"; + vm.Library.FilteredClips.Single().Name.ShouldBe("Saved Without Event"); + } + [Fact] public async Task ClipCount_ReflectsFilteredCount() { diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs index 6e96087..77ea13c 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs @@ -21,6 +21,20 @@ public void EventMarker_NearClipEnd_MapsToHighFraction() vm.Playback.EventMarkerTooltip.ShouldStartWith("Honk · "); } + [Fact] + public void EventMarkerTooltip_SavedClipWithBlankReason_SaysSavedLikeItsCard() + { + var vm = CreateViewModel(); + var start = new DateTime(2025, 1, 1, 12, 0, 0); + var camEvent = new CamEvent { Reason = " ", Timestamp = start.AddSeconds(30) }; + + // The card names this clip by its SavedClips folder, so the marker over the same clip must not call it Recent. + vm.Library.SelectedClip = new CamClip(@"D:\TeslaCam\SavedClips\2025-01-01_12-00-00", "Saved Clip", start, [new CamChunk(start, [])], camEvent); + + vm.Playback.HasEventMarker.ShouldBeTrue(); + vm.Playback.EventMarkerTooltip.ShouldStartWith("Saved · "); + } + [Fact] public void EventMarker_AbsentWithoutEvent() { diff --git a/SentryDeck/ClipConverters.cs b/SentryDeck/ClipConverters.cs index bc2cd94..d4a28f3 100644 --- a/SentryDeck/ClipConverters.cs +++ b/SentryDeck/ClipConverters.cs @@ -9,21 +9,22 @@ namespace SentryDeck; /// -/// Friendly event-reason label for a (e.g. "Sentry", "Honk", "Saved"). +/// Friendly event-reason label for a or (e.g. "Sentry", "Honk", "Saved"). +/// Bind the clip where there is one: a clip without event.json takes its label from its TeslaCam folder, which a bare event can't tell. /// public sealed class ReasonLabelConverter : MarkupExtension, IValueConverter { public override object ProvideValue(IServiceProvider serviceProvider) => this; public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => ClipDisplay.ReasonLabel(value as CamEvent); + => value is CamClip clip ? ClipDisplay.ReasonLabel(clip) : ClipDisplay.ReasonLabel(value as CamEvent); public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); } /// -/// The stable reason category key for a (see ). +/// The stable reason category key for a or (see ). /// Reason-colored overlays bind this into DataTriggers that pick a theme brush via DynamicResource, so the color follows a live OS light/dark switch. /// (Resolving the brush in the converter instead returned a one-time snapshot that stayed on the old theme.) /// @@ -32,7 +33,7 @@ public sealed class ReasonKeyConverter : MarkupExtension, IValueConverter public override object ProvideValue(IServiceProvider serviceProvider) => this; public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => ClipDisplay.ReasonKey(value as CamEvent); + => value is CamClip clip ? ClipDisplay.ReasonKey(clip) : ClipDisplay.ReasonKey(value as CamEvent); public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); diff --git a/SentryDeck/ClipDisplay.cs b/SentryDeck/ClipDisplay.cs index 30cfb29..af37cb5 100644 --- a/SentryDeck/ClipDisplay.cs +++ b/SentryDeck/ClipDisplay.cs @@ -12,15 +12,28 @@ public static class ClipDisplay public const string ReasonSaved = "saved"; public const string ReasonRecent = "recent"; + /// + /// Normalizes a clip's event reason into a stable category key. + /// A clip with no event.json, or a blank reason, falls back to its TeslaCam folder, so a saved or Sentry clip isn't passed off as the rolling Recent buffer. + /// + public static string ReasonKey(CamClip clip) => ReasonKey(clip?.Event, clip?.SourceFolder ?? CamSourceFolder.Other); + /// /// Normalizes a raw event reason into a stable category key. /// - public static string ReasonKey(CamEvent camEvent) + public static string ReasonKey(CamEvent camEvent) => ReasonKey(camEvent, CamSourceFolder.Other); + + private static string ReasonKey(CamEvent camEvent, CamSourceFolder sourceFolder) { var reason = camEvent?.Reason; if (string.IsNullOrWhiteSpace(reason)) { - return ReasonRecent; + return sourceFolder switch + { + CamSourceFolder.SavedClips => ReasonSaved, + CamSourceFolder.SentryClips => ReasonSentry, + _ => ReasonRecent, + }; } reason = reason.ToLowerInvariant(); @@ -44,10 +57,17 @@ public static string ReasonKey(CamEvent camEvent) return ReasonSaved; } + /// + /// A short, human-friendly label for the clip's event reason, or for its TeslaCam folder when the reason is missing. + /// + public static string ReasonLabel(CamClip clip) => LabelFor(ReasonKey(clip)); + /// /// A short, human-friendly label for the event reason. /// - public static string ReasonLabel(CamEvent camEvent) => ReasonKey(camEvent) switch + public static string ReasonLabel(CamEvent camEvent) => LabelFor(ReasonKey(camEvent)); + + private static string LabelFor(string reasonKey) => reasonKey switch { ReasonSentry => "Sentry", ReasonHonk => "Honk", diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index bdfdbdb..f91c6c3 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -217,19 +217,19 @@ + + + Date: Mon, 5 Oct 2026 22:43:34 -0500 Subject: [PATCH 12/73] Keep the cameras playing after a seek to the end of a clip Clicking the right end of the seek bar, or pressing Right within 5 s of the end, seeked every camera to exactly the clip's duration, which is past each stream's last frame. Flyleaf's accurate seek then found no frame: the video kept the stale pre-seek picture while the time read end / end, and the seek left that camera's decoder thread parked in a state nothing ever wakes. From then on Play showed Pause over frozen video, and every clip opened afterwards stayed frozen on those cameras (the rear often kept playing alone) until the app was restarted. Seeks now stop 100 ms short of where each camera's own footage ends, so they aim at a real frame instead of past the last one, and "go to end" shows the clip's final moments instead of a stale picture. One frame of margin is not enough: Tesla files show their last frame up to about 60 ms before the duration they record, and side cameras often end a few hundred milliseconds before the front (up to about 0.7 s on real footage). The media source therefore reports each camera's footage length, and every seek path clamps each camera to its own end: seek-bar clicks, arrow keys, scrub release, the open-time jump to the event, and the side-camera realignment on resume and frame step. A clip sent to its end still replays from the start on Play. In later testing one real clip still froze its left and right cameras after a paused seek to the end, so this margin may not cover every file, and the stuck decoder itself needs a follow-up. Found in v1.1.0 local testing: F52, F38, F46, F47, F57, and cluster A's new finding (only the rear kept playing). --- SentryDeck.Data/Playback/ClipMediaSource.cs | 14 +- .../Playback/FfconcatMediaSourceBuilder.cs | 7 +- .../FfconcatMediaSourceBuilderTests.cs | 18 +++ SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs | 12 ++ .../Fixtures/FakeClipMediaSourceBuilder.cs | 21 ++- .../MainWindowViewModelTests.Keyboard.cs | 3 +- .../VideoPlayerControllerTests.cs | 144 +++++++++++++++++- SentryDeck/Playback/VideoPlayerController.cs | 57 ++++++- 8 files changed, 260 insertions(+), 16 deletions(-) diff --git a/SentryDeck.Data/Playback/ClipMediaSource.cs b/SentryDeck.Data/Playback/ClipMediaSource.cs index 1e4a1ea..4b393c2 100644 --- a/SentryDeck.Data/Playback/ClipMediaSource.cs +++ b/SentryDeck.Data/Playback/ClipMediaSource.cs @@ -15,7 +15,8 @@ public sealed record class ClipMediaSource( IReadOnlyList AutoExcludedChunkIndices, IReadOnlyList ChunkTimestamps = null, IReadOnlyList ChunkDurations = null, - DateTime? ClipStartTimestamp = null) + DateTime? ClipStartTimestamp = null, + IReadOnlyDictionary CameraDurations = null) { /// /// How large a wall-clock gap between consecutive included chunks must be before it counts as a real discontinuity worth marking on the timeline, rather than the normal small skew between a chunk's nominal timestamp and the previous chunk's probed end. @@ -26,6 +27,17 @@ public sealed record class ClipMediaSource( private IReadOnlyList ChunkDurations { get; } = ChunkDurations ?? []; + private IReadOnlyDictionary CameraDurations { get; } = CameraDurations ?? new Dictionary(); + + /// + /// How far into the shared timeline 's own footage runs, which can be short of . + /// A missing or unreadable later file truncates that camera's playlist, and Tesla's side files often stop a few hundred milliseconds before the front file of the same minute. + /// A player seeked past the end of its own footage has no frame to land on, so callers need this per camera rather than the front-driven . + /// Falls back to for a camera the builder didn't measure. + /// + public TimeSpan DurationOf(string camera) => + camera is not null && CameraDurations.TryGetValue(camera, out var duration) ? duration : Duration; + /// /// Media-time positions where the preceding wall-clock gap between chunks exceeds -- i.e. where playback jumps forward in time even though it plays through with no visible stall. /// Empty for a clip with no gaps (or fewer than two chunks, or when timestamp/duration data wasn't supplied). diff --git a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs index 79ed949..3bb3a6e 100644 --- a/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs +++ b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs @@ -63,6 +63,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice } var playlistPaths = new Dictionary(); + var cameraDurations = new Dictionary(); // Distinct clips can share a display name; only the folder path is unique per clip, so it is hashed into the filename to keep two clips (built concurrently or not) from clobbering each other's playlists. // Must stay deterministic per clip: rebuilds overwrite in place. @@ -115,6 +116,10 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice var playlistPath = Path.Combine(PlaylistDirectory, $"{clipToken}-{camera}.ffconcat"); WritePlaylist(playlistPath, entries); playlistPaths[camera] = playlistPath; + + // Every entry but the last starts where the front says, so only the last file's own length decides where this camera's footage runs out. + var lastFileDuration = Mp4DurationReader.TryReadDuration(entries[^1].FilePath) ?? entries[^1].Duration; + cameraDurations[camera] = chunkStarts[entries.Count - 1] + lastFileDuration; } var duration = chunkStarts.Count == 0 @@ -126,7 +131,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice // The clip's ORIGINAL start (even if that chunk was excluded), so ToMediaTime can tell an event inside excluded leading footage (snap to media time zero) from pre-clip clock skew. DateTime? clipStartTimestamp = clip.Chunks.Count > 0 ? clip.Chunks[0].Timestamp : null; - return new ClipMediaSource(duration, chunkStarts, playlistPaths, autoExcludedIndices, chunkTimestamps, chunkDurations, clipStartTimestamp); + return new ClipMediaSource(duration, chunkStarts, playlistPaths, autoExcludedIndices, chunkTimestamps, chunkDurations, clipStartTimestamp, cameraDurations); } private static TimeSpan? ProbeFrontChunkDuration(CamChunk chunk) diff --git a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs index f43d809..39dd25b 100644 --- a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs +++ b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs @@ -397,6 +397,24 @@ public void Build_TruncatedFrontWithAReadableNumberedCopy_PlaysTheCopy() File.ReadAllText(mediaSource.CameraPlaylistPaths[CameraNames.Front]).ShouldContain(FfconcatMediaSourceBuilder.EscapeConcatPath(copyPath)); } + [Fact] + public void Build_SideCamerasEndingEarly_ReportWhereEachCamerasFootageEnds() + { + // A player seeked past the end of its own footage has no frame to land on, so each camera's end must be known, not just the front's. + // Tesla's side files often stop a few hundred milliseconds before the front file of the same minute, and an unreadable file truncates a camera outright. + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.LeftRepeater), TestMp4.BuildWithDuration(TimeSpan.FromSeconds(59.4))); + File.WriteAllBytes(clipFiles.GetPath(1, CameraNames.Back), TestMp4.GarbageBytes); + + var mediaSource = Build(clipFiles.Clip); + + mediaSource.Duration.ShouldBe(TimeSpan.FromSeconds(120)); + mediaSource.DurationOf(CameraNames.Front).ShouldBe(TimeSpan.FromSeconds(120)); + mediaSource.DurationOf(CameraNames.LeftRepeater).ShouldBe(TimeSpan.FromSeconds(119.4)); + mediaSource.DurationOf(CameraNames.Back).ShouldBe(TimeSpan.FromSeconds(60)); + mediaSource.DurationOf(CameraNames.RightRepeater).ShouldBe(TimeSpan.FromSeconds(120)); + } + // --- Chunks whose probed durations differ from their nominal one-minute spacing --- [Fact] diff --git a/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs b/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs index b75ccec..37d0c15 100644 --- a/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs +++ b/SentryDeck.Tests/Fixtures/FakeCameraPlayer.cs @@ -34,6 +34,17 @@ internal sealed class FakeCameraPlayer : ICameraPlayer /// Invoked inside after the seek is recorded, so a test can act while a seek is executing. public Action SeekCallback { get; set; } + /// + /// When set, the timestamp of the stream's last frame; an accurate seek past it wedges the player (see ). + /// + public TimeSpan? LastFrame { get; set; } + + /// + /// Set once an accurate seek lands past . + /// The real player finds no frame there and its decoder thread is never woken again, so that camera stays frozen on every clip until the app restarts. + /// + public bool IsWedged { get; private set; } + public bool IsOpen { get; private set; } public bool IsEnded { get; private set; } @@ -161,6 +172,7 @@ public Task SeekAsync(TimeSpan position, bool accurate = true) Record(accurate ? $"seek:{position.TotalSeconds}" : $"scrub:{position.TotalSeconds}", () => _seeks.Add((position, accurate))); SeekCallback?.Invoke(); + IsWedged |= accurate && position > LastFrame; IsEnded = false; MoveTo(position); return Task.CompletedTask; diff --git a/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs b/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs index d6afdd5..6316a05 100644 --- a/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs +++ b/SentryDeck.Tests/Fixtures/FakeClipMediaSourceBuilder.cs @@ -14,6 +14,7 @@ internal sealed class FakeClipMediaSourceBuilder : IClipMediaSourceBuilder private readonly List> _exclusionsPerBuild = []; private readonly List _clipsPerBuild = []; private readonly HashSet _autoExcludeChunkIndices = []; + private readonly Dictionary _cameraShortfalls = []; public int BuildCount { @@ -82,17 +83,31 @@ public void AutoExcludeChunk(int chunkIndex) } } + /// + /// Makes 's footage stop before the end of its playlist, like a Tesla side file that ends a little before the front file of the same minute. + /// Reported through . + /// + public void ShortenCamera(string camera, TimeSpan shortfall) + { + lock (_recordingLock) + { + _cameraShortfalls[camera] = shortfall; + } + } + public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndices = null) { // Snapshot the set before recording: the controller passes (and later mutates) its live exclusion set, so recording the reference would retroactively rewrite earlier entries. var exclusionsSnapshot = excludedChunkIndices is null ? new HashSet() : new HashSet(excludedChunkIndices); HashSet autoExcluded; + Dictionary cameraShortfalls; lock (_recordingLock) { _clipsPerBuild.Add(clip); _exclusionsPerBuild.Add(exclusionsSnapshot); autoExcluded = [.. _autoExcludeChunkIndices]; + cameraShortfalls = new Dictionary(_cameraShortfalls); } var autoExcludedIndices = Enumerable.Range(0, clip.Chunks.Count) @@ -115,6 +130,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice var chunkDurations = includedIndices.Select(_ => ChunkDuration).ToList(); var playlistPaths = new Dictionary(); + var cameraDurations = new Dictionary(); foreach (var camera in CameraNames.All) { if (includedIndices.Count == 0 || !clip.Chunks[includedIndices[0]].Files.TryGetValue(camera, out var firstFile)) @@ -124,6 +140,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice // Mirror the real builder: stop at the first (remaining) chunk missing this camera's file. var lastAvailableFile = firstFile; + var entryCount = 1; for (var i = 1; i < includedIndices.Count; i++) { if (!clip.Chunks[includedIndices[i]].Files.TryGetValue(camera, out var file)) @@ -132,6 +149,7 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice } lastAvailableFile = file; + entryCount++; } var playlistPath = $"{lastAvailableFile.FullPath}.fake-{camera}.ffconcat"; @@ -141,11 +159,12 @@ public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndice } playlistPaths[camera] = playlistPath; + cameraDurations[camera] = TimeSpan.FromTicks(ChunkDuration.Ticks * entryCount) - cameraShortfalls.GetValueOrDefault(camera); } // Mirror the real builder: the clip's original start, even when leading chunks are excluded. DateTime? clipStartTimestamp = clip.Chunks.Count > 0 ? clip.Chunks[0].Timestamp : null; - return new ClipMediaSource(duration, chunkStarts, playlistPaths, autoExcludedIndices, chunkTimestamps, chunkDurations, clipStartTimestamp); + return new ClipMediaSource(duration, chunkStarts, playlistPaths, autoExcludedIndices, chunkTimestamps, chunkDurations, clipStartTimestamp, cameraDurations); } } diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs index 4200209..16f1133 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs @@ -27,7 +27,8 @@ public void ArrowKeys_SeekFiveSecondsAndClampAtTheEnds() controller.Position = TimeSpan.FromSeconds(60); // parked at the very end RunPinnedToTestThread(() => vm.HandleKeyDownAsync(Key.Right, ModifierKeys.None)); - front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(60)); + // Stops just short of the end: a seek past the last frame would freeze the camera for good. + front.Seeks[^1].Position.ShouldBe(TimeSpan.FromSeconds(60) - VideoPlayerController.EndSeekMargin); } [Fact] diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index d0e9815..085717b 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -13,8 +13,15 @@ public sealed partial class VideoPlayerControllerTests { private static readonly TimeSpan ChunkDuration = FakeClipMediaSourceBuilder.ChunkDuration; + /// + /// How far before the end of its recorded duration a real Tesla file shows its last frame, at the worst measured on real footage: the file's final sample falls outside its edit list. + /// + private static readonly TimeSpan LastFrameCut = TimeSpan.FromMilliseconds(58); + private sealed class Rig : IDisposable { + private readonly Dictionary _shortfalls = []; + public Rig( int chunkCount = 3, FakeCameraPlayer front = null, @@ -65,6 +72,26 @@ public async Task OpenAsync(params CamClip[] clips) await Controller.WhenIdleAsync(); } + /// + /// Ends 's footage before the clip's, like a Tesla side file that stops before the front file of the same minute. + /// + public void ShortenCamera(string camera, TimeSpan shortfall) + { + FakeBuilder.ShortenCamera(camera, shortfall); + _shortfalls[camera] = shortfall; + } + + /// + /// Puts every camera's last frame where a real Tesla file has it, before the end of its footage, so seeking a camera past it wedges the fake the way it wedges Flyleaf. + /// + public void PlaceLastFrames(TimeSpan footageEnd) + { + foreach (var (camera, player) in Players) + { + player.LastFrame = footageEnd - _shortfalls.GetValueOrDefault(camera) - LastFrameCut; + } + } + public void Dispose() { Controller.Dispose(); @@ -241,6 +268,23 @@ public async Task SelectingClip_WithNoJumpTarget_OpensAtTopOfBufferWithoutSeekin rig.Controller.IsPlaying.ShouldBeTrue(); } + [Fact] + public async Task SelectingClip_WithEventPastWhereASideCamerasFootageEnds_StartsThatCameraOnItsLastFrame() + { + // The event sits 55 s into the second chunk, so the clip opens at 1:45, but the left camera's footage stops at 1:30. + using var rig = new Rig(chunkCount: 2); + rig.ShortenCamera(CameraNames.LeftRepeater, TimeSpan.FromSeconds(30)); + rig.PlaceLastFrames(ChunkDuration * 2); + var clip = WithEvent(rig.Clip, rig.Clip.Chunks[1].Timestamp.AddSeconds(55)); + + await rig.OpenAsync(clip); + + rig.Front.Seeks.ShouldHaveSingleItem().Position.ShouldBe(TimeSpan.FromSeconds(105)); + rig.Left.Seeks.ShouldHaveSingleItem().Position.ShouldBe(TimeSpan.FromSeconds(90) - VideoPlayerController.EndSeekMargin); + rig.All.ShouldAllBe(player => !player.IsWedged && player.IsPlaying); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(105)); + } + [Fact] public async Task SelectingClip_WhileAnotherIsStillOpening_OnlyTheLatestPlays() { @@ -339,6 +383,28 @@ public async Task PlayAsync_WhenSideCameraDriftedWhilePaused_RealignsItOntoTheFr rig.Right.Seeks.ShouldBeEmpty(); } + [Fact] + public async Task PlayAsync_WhenTheFrontIsPastWhereASideCamerasFootageEnded_RealignsThatCameraOnlyAsFarAsItsLastFrame() + { + // The left camera ran out of footage a second before the front and ended there while the front played on. + // Lining it up with the front on resume must not ask it for a moment it has no frame for. + using var rig = new Rig(chunkCount: 1); + var leftEnd = ChunkDuration - TimeSpan.FromSeconds(1); + rig.ShortenCamera(CameraNames.LeftRepeater, TimeSpan.FromSeconds(1)); + rig.PlaceLastFrames(ChunkDuration); + await rig.OpenAsync(); + rig.Left.RaiseEnded(at: leftEnd - LastFrameCut); + rig.Front.RaisePositionChanged(ChunkDuration - TimeSpan.FromSeconds(0.5)); + await rig.Controller.PauseAsync(); + + await rig.Controller.PlayAsync(); + + rig.Left.IsWedged.ShouldBeFalse(); + rig.Left.Seeks.ShouldHaveSingleItem().Position.ShouldBe(leftEnd - VideoPlayerController.EndSeekMargin); + rig.Front.IsPlaying.ShouldBeTrue(); + rig.Controller.IsPlaying.ShouldBeTrue(); + } + [Fact] public async Task PlayAsync_AfterStop_ReopensTheClip() { @@ -444,7 +510,7 @@ public async Task SeekAsync_WhilePaused_SeeksWithoutResuming() } [Fact] - public async Task SeekAsync_BeyondDuration_ClampsToDuration() + public async Task SeekAsync_BeyondDuration_StopsJustShortOfTheEnd() { using var rig = new Rig(chunkCount: 3); await rig.OpenAsync(); @@ -452,8 +518,58 @@ public async Task SeekAsync_BeyondDuration_ClampsToDuration() await rig.Controller.SeekAsync(TimeSpan.FromMinutes(10)); - rig.Front.Seeks[^1].Position.ShouldBe(ChunkDuration * 3); - rig.Controller.Position.ShouldBe(ChunkDuration * 3); + rig.Front.Seeks[^1].Position.ShouldBe((ChunkDuration * 3) - VideoPlayerController.EndSeekMargin); + rig.Controller.Position.ShouldBe((ChunkDuration * 3) - VideoPlayerController.EndSeekMargin); + } + + [Theory] + [InlineData("seek")] + [InlineData("seek by")] + [InlineData("scrub release")] + public async Task SeekingToTheEnd_ThenPlaying_ReplaysWithoutSeekingAnyCameraPastItsLastFrame(string route) + { + // An accurate seek past a stream's last frame leaves Flyleaf no frame to show and its decoder parked for good, so that camera froze on every clip until a restart. + // The left camera's file stops 600 ms before the front's, as Tesla side files often do, so each camera has to stop at its own last frame rather than the front's. + using var rig = new Rig(chunkCount: 2); + var end = ChunkDuration * 2; + var leftShortfall = TimeSpan.FromMilliseconds(600); + rig.ShortenCamera(CameraNames.LeftRepeater, leftShortfall); + rig.PlaceLastFrames(end); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + + switch (route) + { + case "seek": + await rig.Controller.SeekAsync(end); + break; + case "seek by": + await rig.Controller.SeekAsync(end - TimeSpan.FromSeconds(2)); + await rig.Controller.SeekByAsync(TimeSpan.FromSeconds(5)); + break; + case "scrub release": + await rig.Controller.BeginScrubAsync(); + await rig.Controller.ScrubSeekAsync(end); + await rig.Controller.EndScrubAsync(end); + break; + } + + rig.All.ShouldAllBe(player => !player.IsWedged); + rig.Front.Seeks[^1].Position.ShouldBe(end - VideoPlayerController.EndSeekMargin); + rig.Back.Seeks[^1].Position.ShouldBe(end - VideoPlayerController.EndSeekMargin); + rig.Left.Seeks[^1].Position.ShouldBe(end - leftShortfall - VideoPlayerController.EndSeekMargin); + rig.Controller.Position.ShouldBe(end - VideoPlayerController.EndSeekMargin); + + await rig.Controller.PlayAsync(); + + // Still close enough to the end that play replays the clip from the top. + foreach (var (camera, player) in rig.Players) + { + player.Calls.TakeLast(2).ShouldBe(["seek:0", "play"], camera); + player.IsPlaying.ShouldBeTrue(camera); + } + + rig.Controller.Position.ShouldBe(TimeSpan.Zero); } [Fact] @@ -510,7 +626,7 @@ public async Task SeekByAsync_PastEitherEnd_ClampsToTheClip() rig.Controller.Position.ShouldBe(TimeSpan.Zero); await rig.Controller.SeekByAsync(TimeSpan.FromMinutes(5)); - rig.Controller.Position.ShouldBe(ChunkDuration); + rig.Controller.Position.ShouldBe(ChunkDuration - VideoPlayerController.EndSeekMargin); } [Fact] @@ -619,6 +735,26 @@ public async Task StepFrameAsync_Backward_SideCamerasFollowTheFrontsNewFrame() rig.Controller.Position.ShouldBe(anchor); } + [Fact] + public async Task StepFrameAsync_PastWhereASideCamerasFootageEnds_LeavesThatCameraOnItsLastFrame() + { + // Past the end of its own footage a camera only has its last frame to show, so it follows the front there and then stays put instead of being reseeked on every step. + using var rig = new Rig(chunkCount: 1); + var leftLimit = ChunkDuration - TimeSpan.FromSeconds(1) - VideoPlayerController.EndSeekMargin; + rig.ShortenCamera(CameraNames.LeftRepeater, TimeSpan.FromSeconds(1)); + rig.PlaceLastFrames(ChunkDuration); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + await rig.Controller.SeekAsync(ChunkDuration - TimeSpan.FromSeconds(0.5)); + + await rig.Controller.StepFrameAsync(forward: false); + await rig.Controller.StepFrameAsync(forward: true); + + rig.Left.IsWedged.ShouldBeFalse(); + rig.Left.Seeks.ShouldAllBe(seek => seek.Position == leftLimit); + rig.Left.Calls[^1].ShouldBe("step:forward"); + } + [Fact] public async Task StepFrameAsync_WhenNothingIsOpen_DoesNothing() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index e65c2ec..1edc867 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -54,6 +54,14 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl /// private static readonly TimeSpan ReplayFromEndWindow = TimeSpan.FromMilliseconds(250); + /// + /// How far before the end of a camera's own footage its seeks stop. + /// An accurate Flyleaf seek past a stream's last frame finds no frame to show and leaves that camera's decoder thread parked for good, so the camera stays frozen on every clip until the app restarts. + /// Tesla files show their last frame up to about 60 ms before the duration they record (the final sample falls outside the file's edit list), so one frame of margin is not enough. + /// A tenth of a second clears every file measured with room to spare, and stays well inside so play still replays a clip sent to its end. + /// + internal static readonly TimeSpan EndSeekMargin = TimeSpan.FromMilliseconds(100); + private readonly string _primaryCamera; private readonly ICameraPlayer _primaryPlayer; private readonly IReadOnlyDictionary _players; @@ -298,8 +306,8 @@ public Task StepFrameAsync(bool forward) => RunOperationAsync(_session, "Frame s var anchor = _primaryPlayer.Position; await Task.WhenAll(SecondaryPlayers() - .Where(player => !forward || (player.Position - anchor).Duration() > StepAlignmentTolerance) - .Select(player => player.SeekAsync(anchor))); + .Where(player => !forward || !IsAlignedWith(player, anchor, StepAlignmentTolerance)) + .Select(player => player.SeekAsync(SeekTargetFor(player, anchor)))); Position = Clamp(anchor, TimeSpan.Zero, Duration); }); @@ -483,6 +491,9 @@ private async Task OpenSourceAsync(Session session, ClipMediaSource mediaSource, } Duration = mediaSource.Duration; + session.SeekLimits = _players.ToDictionary( + pair => pair.Value, + pair => Clamp(mediaSource.DurationOf(pair.Key) - EndSeekMargin, TimeSpan.Zero, Duration)); var opens = _players.Select(async pair => (pair.Key, Opened: await OpenCameraAsync(pair.Key, pair.Value, mediaSource))).ToList(); var results = await Task.WhenAll(opens); @@ -496,10 +507,10 @@ private async Task OpenSourceAsync(Session session, ClipMediaSource mediaSource, ApplyPlaybackSpeed(); - var start = Clamp(startPosition, TimeSpan.Zero, Duration); + var start = Clamp(startPosition, TimeSpan.Zero, SeekableEnd); if (start > TimeSpan.Zero) { - await ForEachOpenPlayerAsync(player => player.SeekAsync(start)); + await ForEachOpenPlayerAsync(player => player.SeekAsync(SeekTargetFor(player, start))); token.ThrowIfCancellationRequested(); } @@ -632,7 +643,7 @@ private async Task RepositionAsync(TimeSpan position, bool accurate) if (!IsSessionOpen || Duration <= TimeSpan.Zero) return; - var target = Clamp(position, TimeSpan.Zero, Duration); + var target = Clamp(position, TimeSpan.Zero, SeekableEnd); var playersRunning = IsPlaying && !_isScrubbing; if (playersRunning) @@ -640,7 +651,7 @@ private async Task RepositionAsync(TimeSpan position, bool accurate) await ForEachOpenPlayerAsync(player => player.PauseAsync()); } - await ForEachOpenPlayerAsync(player => player.SeekAsync(target, accurate)); + await ForEachOpenPlayerAsync(player => player.SeekAsync(SeekTargetFor(player, target), accurate)); Position = target; if (playersRunning) @@ -656,7 +667,7 @@ private Task AlignSecondaryCamerasAsync() { var anchor = _primaryPlayer.Position; var drifted = SecondaryPlayers() - .Where(player => player.IsEnded || (player.Position - anchor).Duration() > ResumeAlignmentTolerance) + .Where(player => player.IsEnded || !IsAlignedWith(player, anchor, ResumeAlignmentTolerance)) .ToList(); if (drifted.Count == 0) @@ -665,7 +676,32 @@ private Task AlignSecondaryCamerasAsync() } Log.Debug("Realigning side cameras before resuming. Count={Count}; Anchor={Anchor}", drifted.Count, anchor); - return Task.WhenAll(drifted.Select(player => player.SeekAsync(anchor))); + return Task.WhenAll(drifted.Select(player => player.SeekAsync(SeekTargetFor(player, anchor)))); + } + + /// + /// The furthest the shared timeline can be seeked: the front's last frame, since the front drives the clock. + /// + private TimeSpan SeekableEnd => SeekTargetFor(_primaryPlayer, Duration); + + /// + /// Where goes for a seek to : there, or its own last frame when its footage runs out sooner. + /// + private TimeSpan SeekTargetFor(ICameraPlayer player, TimeSpan position) => + _session?.SeekLimits.TryGetValue(player, out var limit) == true && position > limit ? limit : position; + + /// + /// Whether already shows what a seek to would show it. + /// + private bool IsAlignedWith(ICameraPlayer player, TimeSpan anchor, TimeSpan tolerance) + { + var target = SeekTargetFor(player, anchor); + + // Past the end of its own footage a camera only has its last frame to show, and anywhere from its seek limit on already shows it. + // Reseeking it there on every frame step would only make it jump back and forth. + return target < anchor + ? player.Position >= target + : (player.Position - target).Duration() <= tolerance; } private async Task PlayAllAsync() @@ -1019,6 +1055,11 @@ private sealed class Session(CamClip clip) public ClipMediaSource Source { get; set; } + /// + /// The furthest each player can be seeked and still land on one of its own frames (see ). + /// + public IReadOnlyDictionary SeekLimits { get; set; } = new Dictionary(); + /// True once every camera is open and positioned; false while opening, after a failure, or once replaced. public bool IsOpen { get; set; } From e6965fc8aaca1cb47be932c9c0423caf60caa02f Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:38:43 -0500 Subject: [PATCH 13/73] Line the cameras up on one moment when fast playback pauses After fast-forwarding at 8x or 16x and pausing, the side camera views showed moments up to about 1.5 s away from the front, each off by a different amount, and the time readout sat about 0.3 s behind the front picture. Frame steps from there could keep the side cameras several frames ahead. Each camera's player keeps its own clock. A camera that can't decode fast enough drops its late frames and restarts its clock wherever its decoder caught up, so the cameras drift apart while playing fast, and pausing froze that drift as it was. At that speed a camera's reported time also trails the frame it shows, because the next frame is drawn before its time is published. Pausing now lines the cameras up on the front's moment. After faster-than-real-time playback every camera, the front included, is seeked there, so every view and the readout show the same instant. At real time only a side camera that is actually out of step moves. A frame step that has to pause playback first does the same before stepping. Found in v1.1.0 local testing: F90, F94, and cluster F's new finding (readout behind the picture after a 16x pause). --- .../VideoPlayerControllerTests.cs | 67 +++++++++++++++++++ SentryDeck/Playback/VideoPlayerController.cs | 62 ++++++++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index 085717b..c5dae4b 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -347,6 +347,51 @@ public async Task PauseAsync_WhilePlaying_PausesEveryCamera() rig.Controller.IsMediaOpen.ShouldBeTrue(); } + [Fact] + public async Task PauseAsync_AfterFastPlayback_LinesEveryCameraUpOnTheFront() + { + // At 16x each camera drops the frames it can't decode in time and restarts its own clock, so the cameras drift apart while playing. + // Each one's reported time also trails the frame it shows, so even a camera that looks aligned (right) and the front itself have to be reseeked for the still frame to match the readout. + using var rig = new Rig(); + await rig.OpenAsync(); + rig.Controller.PlaybackSpeed = 16; + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(30)); + rig.Back.RaisePositionChanged(TimeSpan.FromSeconds(29.2)); + rig.Left.RaisePositionChanged(TimeSpan.FromSeconds(31.4)); + rig.Right.RaisePositionChanged(TimeSpan.FromSeconds(30.01)); + + await rig.Controller.PauseAsync(); + + foreach (var (camera, player) in rig.Players) + { + player.Calls.TakeLast(2).ShouldBe(["pause", "seek:30"], camera); + player.IsPlaying.ShouldBeFalse(camera); + } + + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(30)); + rig.Controller.IsPlaying.ShouldBeFalse(); + } + + [Fact] + public async Task PauseAsync_AtNormalSpeed_RealignsOnlyACameraThatIsOutOfStep() + { + // At real time each camera's time matches its picture, so only one that has really drifted (here, left behind by an earlier fast stretch) moves, and the front stays on the frame the user paused on. + using var rig = new Rig(); + await rig.OpenAsync(); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(30)); + rig.Back.RaisePositionChanged(TimeSpan.FromSeconds(28.9)); + rig.Left.RaisePositionChanged(TimeSpan.FromSeconds(30.03)); + rig.Right.RaisePositionChanged(TimeSpan.FromSeconds(29.98)); + + await rig.Controller.PauseAsync(); + + rig.Back.Seeks.ShouldHaveSingleItem().Position.ShouldBe(TimeSpan.FromSeconds(30)); + rig.Front.Seeks.ShouldBeEmpty(); + rig.Left.Seeks.ShouldBeEmpty(); + rig.Right.Seeks.ShouldBeEmpty(); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(30)); + } + [Fact] public async Task PlayAsync_OnPausedClip_ResumesEveryCameraWithoutReopening() { @@ -713,6 +758,28 @@ public async Task StepFrameAsync_ForwardWhenASideCameraSlipped_ReseeksOnlyThatCa rig.Right.Seeks.ShouldBeEmpty(); } + [Fact] + public async Task StepFrameAsync_WhilePlayingFast_LinesTheCamerasUpBeforeStepping() + { + // Stepping on from wherever 16x left each camera kept the side cameras several frames off the front, and the next step didn't fix it. + using var rig = new Rig(); + await rig.OpenAsync(); + rig.Controller.PlaybackSpeed = 16; + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(30)); + rig.Back.RaisePositionChanged(TimeSpan.FromSeconds(30.2)); + + await rig.Controller.StepFrameAsync(forward: true); + + var stepped = TimeSpan.FromSeconds(30) + FakeCameraPlayer.FrameDuration; + foreach (var (camera, player) in rig.Players) + { + player.Calls.TakeLast(3).ShouldBe(["pause", "seek:30", "step:forward"], camera); + player.Position.ShouldBe(stepped, camera); + } + + rig.Controller.Position.ShouldBe(stepped); + } + [Fact] public async Task StepFrameAsync_Backward_SideCamerasFollowTheFrontsNewFrame() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 1edc867..de29e44 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -13,6 +13,7 @@ namespace SentryDeck; /// Anything that talks to the players runs as a serialized operation, so a seek never interleaves with a clip change or a recovery, and events that arrive mid-operation queue behind it. /// Every opened clip is a ; replacing or stopping it cancels its token, and queued work for a session that is no longer current does nothing. /// Cameras stay in lockstep because every reposition pauses all players, seeks them all to the same instant, and only then resumes them together. +/// Each player still runs its own clock while playing, so fast playback can pull them apart; they are lined up again whenever playback pauses (see ). /// public sealed partial class VideoPlayerController : ObservableObject, IDisposable { @@ -38,10 +39,10 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl private static readonly TimeSpan EventLeadIn = TimeSpan.FromSeconds(10); /// - /// How far a paused side camera may sit from the front before resuming realigns it. + /// How far a paused side camera may sit from the front before pausing or resuming realigns it. /// Pausing stops each camera's play thread independently, so they park a frame or two apart; anything beyond that means a camera fell behind and would stay behind. /// - private static readonly TimeSpan ResumeAlignmentTolerance = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan PausedAlignmentTolerance = TimeSpan.FromMilliseconds(100); /// /// How far a side camera may sit from the front after a forward frame step before it is reseeked onto the front's frame. @@ -199,9 +200,17 @@ public Task PlayAsync() public Task PauseAsync() => RunOperationAsync(_session, "Playback error", async _ => { + // A scrub already holds the players paused, and its release seek lines them up. + var wasPlaying = IsPlaying && !_isScrubbing; _resumeAfterScrub = false; await ForEachOpenPlayerAsync(player => player.PauseAsync()); IsPlaying = false; + + if (wasPlaying) + { + await LineUpPausedCamerasAsync(); + } + Log.Debug("Paused playback. ClipName={ClipName}; Position={Position}", CurrentClip?.Name, Position); }); @@ -291,6 +300,9 @@ public Task StepFrameAsync(bool forward) => RunOperationAsync(_session, "Frame s { await ForEachOpenPlayerAsync(player => player.PauseAsync()); IsPlaying = false; + + // Stepping on from wherever fast playback left each camera would keep them apart, so they start the step from one frame. + await LineUpPausedCamerasAsync(); } _resumeAfterScrub = false; @@ -667,7 +679,7 @@ private Task AlignSecondaryCamerasAsync() { var anchor = _primaryPlayer.Position; var drifted = SecondaryPlayers() - .Where(player => player.IsEnded || !IsAlignedWith(player, anchor, ResumeAlignmentTolerance)) + .Where(player => player.IsEnded || !IsAlignedWith(player, anchor, PausedAlignmentTolerance)) .ToList(); if (drifted.Count == 0) @@ -679,6 +691,50 @@ private Task AlignSecondaryCamerasAsync() return Task.WhenAll(drifted.Select(player => player.SeekAsync(SeekTargetFor(player, anchor)))); } + /// + /// Lines the just-paused cameras up on the front's moment, and moves the readout onto the frame the front then shows. + /// + private async Task LineUpPausedCamerasAsync() + { + // An ended front is parked by the end handler queued behind this. + if (!IsSessionOpen || !_primaryPlayer.IsOpen || _primaryPlayer.IsEnded) + return; + + var anchor = Clamp(_primaryPlayer.Position, TimeSpan.Zero, SeekableEnd); + if (await LineUpCamerasAsync(anchor)) + { + Position = anchor; + } + } + + /// + /// Once playback has paused, seeks every camera that isn't showing the moment at onto it (or onto its own last frame when its footage ends sooner), so the still frame the user studies shows every camera at the same moment as the time readout. + /// + /// + /// Every camera runs its own clock. + /// Faster than real time, a camera that can't decode every frame drops the late ones and restarts its clock wherever its decoder caught up, so at 16x the cameras drift up to a second and a half apart while playing. + /// A camera's reported time also trails the frame it shows by up to a third of a second then, because the next frame is drawn before its time is published. + /// So after fast playback no camera's position can be trusted, and every camera, the front included, is reseeked. + /// At real time the positions are accurate, and only a camera that is actually out of step moves. + /// + /// Whether the front was reseeked, in which case the readout has to follow it. + private async Task LineUpCamerasAsync(TimeSpan anchor) + { + var positionsAreTrustworthy = PlaybackSpeed <= 1.0; + var outOfStep = _players.Values + .Where(player => player.IsOpen && !(positionsAreTrustworthy && IsAlignedWith(player, anchor, PausedAlignmentTolerance))) + .ToList(); + + if (outOfStep.Count == 0) + { + return false; + } + + Log.Debug("Lining the cameras up after pausing. Count={Count}; Anchor={Anchor}; PlaybackSpeed={PlaybackSpeed}", outOfStep.Count, anchor, PlaybackSpeed); + await Task.WhenAll(outOfStep.Select(player => player.SeekAsync(SeekTargetFor(player, anchor)))); + return outOfStep.Contains(_primaryPlayer); + } + /// /// The furthest the shared timeline can be seeked: the front's last frame, since the front drives the clock. /// From 32d331f27999fd185c3c283cd9be592baa6717f5 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:46:18 -0500 Subject: [PATCH 14/73] Show the front's last frame when fast playback reaches the end At 8x or 16x a clip could finish with the front showing a frame up to 0.64 s before its last one while the readout said the clip had ended. A side camera that ran out of footage could also stop on an early frame. Past real time the front drops the frames it can't decode in time, and when its decoder runs out it jumps its clock to the end without drawing the rest. The end handler only paused the cameras and set the readout to the end. When a clip ends, every camera that ran out of footage is now seeked onto its own last frame, and the readout follows the front. At real time a camera already on its last frame is left alone. A side camera that lagged behind and is still mid-stream stays where it stopped, because seeking it to its last moments from there can park its decoder for good: an earlier version that also moved those cameras froze them in one of three 16x runs on real footage. Such a camera can still end up to about 0.7 s short of its end. Found in v1.1.0 local testing: RI-4, and F66 in part. --- .../VideoPlayerControllerTests.Recovery.cs | 57 +++++++++++++++++++ SentryDeck/Playback/VideoPlayerController.cs | 18 +++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs b/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs index 4e97d01..e5331a0 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs @@ -25,6 +25,63 @@ public async Task FrontEnded_AtTheEnd_ParksThereWithoutAdvancingToTheNextClip() rig.FakeBuilder.BuildCount.ShouldBe(1); } + [Fact] + public async Task FrontEnded_AfterFastPlayback_MovesEveryCameraThatRanOutOntoItsLastFrame() + { + // At 16x the front can reach its end on a frame 0.6 s early, having dropped the frames it couldn't decode in time, while the readout claims the end. + // The left camera's shorter file already ran out too, and must not be seeked past its own last frame. + // The rear and right lag behind and are still mid-stream: seeking a camera to its last moments from there can park its decoder for good, so they stay where they stopped. + using var rig = new Rig(chunkCount: 2); + var end = ChunkDuration * 2; + var leftShortfall = TimeSpan.FromMilliseconds(600); + rig.ShortenCamera(CameraNames.LeftRepeater, leftShortfall); + rig.PlaceLastFrames(end); + await rig.OpenAsync(); + rig.Controller.PlaybackSpeed = 16; + var backStop = end - TimeSpan.FromSeconds(1.3); + rig.Back.RaisePositionChanged(backStop); + rig.Left.RaiseEnded(at: end - leftShortfall - LastFrameCut); + rig.Right.RaisePositionChanged(end - TimeSpan.FromSeconds(0.2)); + + rig.Front.RaiseEnded(at: end - TimeSpan.FromSeconds(0.6)); + await rig.Controller.WhenIdleAsync(); + + rig.Front.Seeks.ShouldHaveSingleItem().Position.ShouldBe(end - VideoPlayerController.EndSeekMargin); + rig.Left.Seeks.ShouldHaveSingleItem().Position.ShouldBe(end - leftShortfall - VideoPlayerController.EndSeekMargin); + rig.Back.Seeks.ShouldBeEmpty(); + rig.Right.Seeks.ShouldBeEmpty(); + rig.Back.Position.ShouldBe(backStop); + rig.All.ShouldAllBe(player => !player.IsWedged && !player.IsPlaying); + rig.Controller.Position.ShouldBe(end - VideoPlayerController.EndSeekMargin); + rig.Controller.IsPlaying.ShouldBeFalse(); + + await rig.Controller.PlayAsync(); + + foreach (var (camera, player) in rig.Players) + { + player.Calls.TakeLast(2).ShouldBe(["seek:0", "play"], camera); + } + } + + [Fact] + public async Task FrontEnded_AtNormalSpeed_LeavesEveryCameraOnTheLastFrameItDrew() + { + // At real time a camera draws every frame up to its last, so reseeking one that ran out would only step it back a few frames. + using var rig = new Rig(chunkCount: 1); + var leftShortfall = TimeSpan.FromMilliseconds(600); + rig.ShortenCamera(CameraNames.LeftRepeater, leftShortfall); + rig.PlaceLastFrames(ChunkDuration); + await rig.OpenAsync(); + rig.Left.RaiseEnded(at: ChunkDuration - leftShortfall); + rig.Back.RaisePositionChanged(ChunkDuration - TimeSpan.FromMilliseconds(60)); + + rig.Front.RaiseEnded(at: ChunkDuration); + await rig.Controller.WhenIdleAsync(); + + rig.All.ShouldAllBe(player => player.Seeks.Count == 0); + rig.Controller.Position.ShouldBe(ChunkDuration); + } + [Fact] public async Task PlayAsync_AfterTheClipEnded_ReplaysEveryCameraFromTheStart() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index de29e44..676479c 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -13,7 +13,7 @@ namespace SentryDeck; /// Anything that talks to the players runs as a serialized operation, so a seek never interleaves with a clip change or a recovery, and events that arrive mid-operation queue behind it. /// Every opened clip is a ; replacing or stopping it cancels its token, and queued work for a session that is no longer current does nothing. /// Cameras stay in lockstep because every reposition pauses all players, seeks them all to the same instant, and only then resumes them together. -/// Each player still runs its own clock while playing, so fast playback can pull them apart; they are lined up again whenever playback pauses (see ). +/// Each player still runs its own clock while playing, so fast playback can pull them apart; they are lined up again whenever playback pauses or reaches the end (see ). /// public sealed partial class VideoPlayerController : ObservableObject, IDisposable { @@ -701,14 +701,14 @@ private async Task LineUpPausedCamerasAsync() return; var anchor = Clamp(_primaryPlayer.Position, TimeSpan.Zero, SeekableEnd); - if (await LineUpCamerasAsync(anchor)) + if (await LineUpCamerasAsync(anchor, _players.Values)) { Position = anchor; } } /// - /// Once playback has paused, seeks every camera that isn't showing the moment at onto it (or onto its own last frame when its footage ends sooner), so the still frame the user studies shows every camera at the same moment as the time readout. + /// Once playback has stopped, seeks each of that isn't showing the moment at onto it (or onto its own last frame when its footage ends sooner), so the still frame the user studies shows every camera at the same moment as the time readout. /// /// /// Every camera runs its own clock. @@ -718,10 +718,10 @@ private async Task LineUpPausedCamerasAsync() /// At real time the positions are accurate, and only a camera that is actually out of step moves. /// /// Whether the front was reseeked, in which case the readout has to follow it. - private async Task LineUpCamerasAsync(TimeSpan anchor) + private async Task LineUpCamerasAsync(TimeSpan anchor, IEnumerable players) { var positionsAreTrustworthy = PlaybackSpeed <= 1.0; - var outOfStep = _players.Values + var outOfStep = players .Where(player => player.IsOpen && !(positionsAreTrustworthy && IsAlignedWith(player, anchor, PausedAlignmentTolerance))) .ToList(); @@ -730,7 +730,7 @@ private async Task LineUpCamerasAsync(TimeSpan anchor) return false; } - Log.Debug("Lining the cameras up after pausing. Count={Count}; Anchor={Anchor}; PlaybackSpeed={PlaybackSpeed}", outOfStep.Count, anchor, PlaybackSpeed); + Log.Debug("Lining the cameras up after playback stopped. Count={Count}; Anchor={Anchor}; PlaybackSpeed={PlaybackSpeed}", outOfStep.Count, anchor, PlaybackSpeed); await Task.WhenAll(outOfStep.Select(player => player.SeekAsync(SeekTargetFor(player, anchor)))); return outOfStep.Contains(_primaryPlayer); } @@ -916,7 +916,11 @@ private void OnPlayerEnded(object sender, EventArgs e) // Deliberately no auto-advance to the next clip: each clip is its own incident, and the most likely follow-up is replaying it. // The media stays open so the scrubber and frame-step remain usable to review the final moments, and play replays from the start. - Position = Duration; + // Past real time the front can reach its end on a frame most of a second early: it drops the frames it can't decode in time, then jumps its clock to the end without drawing the rest. + // So every camera that ran out of footage is moved onto its last frame, and the readout follows a front that had to move. + // A side camera still short of its end stays where it stopped: seeking a camera to its last moments while its decoder is still mid-stream can park that decoder for good, which froze side cameras that lagged behind at 16x. + var endedCameras = _players.Values.Where(player => player.IsEnded).ToList(); + Position = await LineUpCamerasAsync(Duration, endedCameras) ? SeekableEnd : Duration; }); } From 9cc36bee3d7409f23207f26e2edcf8df54bcc78e Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:59:18 -0500 Subject: [PATCH 15/73] Keep playing from the same moment when the next video file can't be read If a clip's next video file became unreadable while the clip was playing (locked by another program, moved, or deleted), playback jumped back to the start of the minute just watched, so up to a minute replayed with no explanation. The concat demuxer fails as it reaches the unreadable file, which surfaces at the end of the healthy chunk before it. Recovery probes the files and correctly drops the bad one, but it always reopened at the start of the chunk under the failure position, which here is the healthy chunk that was playing. Recovery now maps the failure position onto the rebuilt timeline: it stays on the same footage when that chunk survives, lands where the following footage begins when the chunk itself was dropped, and accounts for chunks dropped before it. So playback carries straight on into the next readable chunk, or parks at the end when nothing follows. The skipped segment is still only logged; telling the user about it is F19. Found in v1.1.0 local testing: F13. --- .../VideoPlayerControllerTests.Recovery.cs | 65 ++++++++++++++++++- SentryDeck/Playback/VideoPlayerController.cs | 39 ++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs b/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs index e5331a0..2639a92 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.Recovery.cs @@ -232,10 +232,11 @@ public async Task FrontEnded_FarBeforeTheEnd_ExcludesTheFailedChunkAndResumesAtI } [Fact] - public async Task FrontFailed_MidClip_WhenTheProbeFindsTheRealCulprit_KeepsTheHealthyChunk() + public async Task FrontFailed_MidClip_WhenTheProbeFindsTheRealCulprit_KeepsTheHealthyChunkAndResumesWhereItFailed() { // The demuxer reads ahead, so the failure position can sit in a healthy chunk while the corrupt file is the next one. // A probe that now flags chunk 2 must win over the position-derived guess of chunk 1. + // Chunk 1 is still there, so rewinding to its start would only replay what was just watched. using var rig = new Rig(chunkCount: 3); await rig.OpenAsync(); rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(100)); @@ -247,10 +248,72 @@ public async Task FrontFailed_MidClip_WhenTheProbeFindsTheRealCulprit_KeepsTheHe rig.FakeBuilder.BuildCount.ShouldBe(2); rig.FakeBuilder.Exclusions()[1].ShouldBeEmpty(); rig.Controller.Duration.ShouldBe(ChunkDuration * 2); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(100)); rig.Controller.IsPlaying.ShouldBeTrue(); rig.Controller.ErrorMessage.ShouldBeNull(); } + [Fact] + public async Task FrontFailed_AtTheEndOfAChunk_WhenTheNextFileTurnedUnreadable_PlaysOnFromThereIntoTheChunkAfterIt() + { + // A file locked or moved after the clip opened fails as the demuxer reaches it, which surfaces at the end of the healthy chunk before it. + // Dropping that file must carry playback straight on into the chunk after it rather than rewinding the minute just watched. + using var rig = new Rig(chunkCount: 3); + await rig.OpenAsync(); + var failurePosition = ChunkDuration - TimeSpan.FromMilliseconds(130); + rig.Front.RaisePositionChanged(failurePosition); + rig.FakeBuilder.AutoExcludeChunk(1); + + rig.Front.RaiseFailed(new InvalidOperationException("Playback stopped unexpectedly")); + await rig.Controller.WhenIdleAsync(); + + rig.FakeBuilder.Exclusions()[^1].ShouldBeEmpty(); + rig.Controller.Duration.ShouldBe(ChunkDuration * 2); + foreach (var (camera, player) in rig.Players) + { + player.Seeks.ShouldHaveSingleItem(camera).Position.ShouldBe(failurePosition, camera); + player.IsPlaying.ShouldBeTrue(camera); + } + + rig.Controller.Position.ShouldBe(failurePosition); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Controller.ErrorMessage.ShouldBeNull(); + } + + [Fact] + public async Task FrontFailed_WhenTheProbeDropsTheChunkThatWasPlaying_ResumesWhereTheNextChunkNowBegins() + { + // Nothing of the failed chunk is left to play, so playback continues with the footage that followed it. + using var rig = new Rig(chunkCount: 3); + await rig.OpenAsync(); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(90)); + rig.FakeBuilder.AutoExcludeChunk(1); + + rig.Front.RaiseFailed(new InvalidOperationException("Playback stopped unexpectedly")); + await rig.Controller.WhenIdleAsync(); + + rig.Controller.Duration.ShouldBe(ChunkDuration * 2); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(60)); + rig.Controller.IsPlaying.ShouldBeTrue(); + } + + [Fact] + public async Task FrontFailed_WhenTheProbeDropsAnEarlierChunk_ResumesOnTheSameFootage() + { + // Dropping a chunk ahead of the failure shortens the timeline before it, so the same moment of footage now sits one chunk earlier. + using var rig = new Rig(chunkCount: 3); + await rig.OpenAsync(); + rig.Front.RaisePositionChanged(TimeSpan.FromSeconds(130)); + rig.FakeBuilder.AutoExcludeChunk(0); + + rig.Front.RaiseFailed(new InvalidOperationException("Playback stopped unexpectedly")); + await rig.Controller.WhenIdleAsync(); + + rig.Controller.Duration.ShouldBe(ChunkDuration * 2); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(70)); + rig.Controller.IsPlaying.ShouldBeTrue(); + } + [Fact] public async Task FrontEnded_Prematurely_MapsThePositionPastChunksTheBuilderAlreadyDropped() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 676479c..3070f8b 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -965,7 +965,7 @@ private void OnPlayerFailed(object sender, CameraPlaybackFailedEventArgs e) /// Handles the front camera stopping well short of , which means the concat demuxer hit a corrupt or truncated chunk. /// Recovery is probe-first: rebuild with the current exclusions and let the builder's per-file probe find the culprit (the demuxer reads ahead of the presentation position, so the failure position can sit inside a healthy chunk). /// Only when the probe finds nothing new is the chunk containing the failure position excluded. - /// Playback resumes at the start of the chunk that failed, and gives up after attempts on one clip. + /// Playback resumes on the footage where it failed, or where the following footage now begins when the chunk under the failure was excluded, and gives up after attempts on one clip. /// private async Task RecoverAsync(Session session, TimeSpan failurePosition, bool wasPlaying, CancellationToken token) { @@ -979,7 +979,7 @@ private async Task RecoverAsync(Session session, TimeSpan failurePosition, bool badChunkTimelineIndex = i; } - var resumePosition = mediaSource.ChunkStarts.Count > 0 ? mediaSource.ChunkStarts[badChunkTimelineIndex] : TimeSpan.Zero; + var offsetInBadChunk = mediaSource.ChunkStarts.Count > 0 ? failurePosition - mediaSource.ChunkStarts[badChunkTimelineIndex] : TimeSpan.Zero; var positionDerivedIndex = MapTimelineIndexToOriginalChunkIndex(clip, session.ExcludedChunks, badChunkTimelineIndex); if (session.RecoveryAttempts >= MaxRecoveryAttemptsPerClip) @@ -1025,6 +1025,11 @@ private async Task RecoverAsync(Session session, TimeSpan failurePosition, bool } token.ThrowIfCancellationRequested(); + + // A file that turns unreadable mid-playback fails as the demuxer reaches it, at the end of the healthy chunk before it. + // Resuming at that chunk's start would replay up to a minute the user just watched, so playback picks up on the same footage instead. + var excludedAfter = session.ExcludedChunks.Union(rebuilt.AutoExcludedChunkIndices).ToHashSet(); + var resumePosition = MapChunkOffsetToTimeline(rebuilt, excludedAfter, positionDerivedIndex, offsetInBadChunk); await OpenSourceAsync(session, rebuilt, resumePosition, wasPlaying, token); } @@ -1070,6 +1075,36 @@ private static int MapTimelineIndexToOriginalChunkIndex(CamClip clip, IReadOnlyS return -1; } + /// + /// Finds where the footage into the clip's chunk sits on , a timeline built without the chunks in . + /// An excluded chunk has no footage left to show, so a moment inside one lands where the next surviving chunk begins, or at the end when none follows. + /// + private static TimeSpan MapChunkOffsetToTimeline(ClipMediaSource source, IReadOnlySet excluded, int chunkIndex, TimeSpan offsetInChunk) + { + var timelineIndex = 0; + for (var index = 0; index < chunkIndex; index++) + { + if (!excluded.Contains(index)) + { + timelineIndex++; + } + } + + if (timelineIndex >= source.ChunkStarts.Count) + { + return source.Duration; + } + + var chunkStart = source.ChunkStarts[timelineIndex]; + if (excluded.Contains(chunkIndex)) + { + return chunkStart; + } + + var chunkEnd = timelineIndex + 1 < source.ChunkStarts.Count ? source.ChunkStarts[timelineIndex + 1] : source.Duration; + return Clamp(chunkStart + offsetInChunk, chunkStart, chunkEnd); + } + private void OnPositionChanged(object sender, CameraPositionChangedEventArgs e) { if (!ReferenceEquals(sender, _primaryPlayer) || !IsSessionOpen) From d3ee23f71e546101b5221a5de88fb7e0660caecc Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Tue, 6 Oct 2026 00:22:27 -0500 Subject: [PATCH 16/73] Move five seconds for every repeat of a held arrow key Holding Left or Right lost about a quarter to a third of its 5 s steps, so a three-second hold landed a minute or more short of where it should have. Separate presses landed exactly, which made the shortfall look random. Each step is measured from where playback is headed: the target of a seek still waiting to run, or else the on-screen position. But a seek stops counting as waiting the moment it starts running, and the position only moves once every camera has landed. A held key repeats sooner than that, so a repeat arriving mid-seek started again from the old position and the step it added was lost; with several repeats per seek the video could even step back. A running seek's target now counts as the starting point until that seek lands, so every repeat adds its five seconds. Found in v1.1.0 local testing: F51. --- .../VideoPlayerControllerTests.cs | 37 +++++++++++++++++++ SentryDeck/Playback/VideoPlayerController.cs | 24 ++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index c5dae4b..8c992e1 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -660,6 +660,43 @@ public async Task SeekByAsync_BurstQueuedBehindBusyOperation_AddsEveryOffset() rig.Front.Seeks.Count.ShouldBe(2); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SeekByAsync_RepeatsArrivingWhileEachSeekRuns_MoveFiveSecondsPerPress(bool playing) + { + // A held arrow key repeats faster than a seek lands, so each repeat arrives while the seek before it is still running. + // The readout only moves once every camera has landed, so measuring those repeats from it lost about a third of a held key's steps. + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.PauseAsync(); + await rig.Controller.SeekAsync(TimeSpan.FromSeconds(20)); + if (playing) + { + await rig.Controller.PlayAsync(); + } + + const int presses = 6; + var pressed = 1; + var lastPress = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + rig.Front.SeekCallback = () => + { + var press = rig.Controller.SeekByAsync(TimeSpan.FromSeconds(5)); + if (++pressed == presses) + { + rig.Front.SeekCallback = null; + lastPress.SetResult(press); + } + }; + + await rig.Controller.SeekByAsync(TimeSpan.FromSeconds(5)); + await await lastPress.Task; + + rig.Front.Seeks.Select(seek => seek.Position.TotalSeconds).ShouldBe([20, 25, 30, 35, 40, 45, 50]); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(50)); + rig.Controller.IsPlaying.ShouldBe(playing); + } + [Fact] public async Task SeekByAsync_PastEitherEnd_ClampsToTheClip() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 3070f8b..8ea9b54 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -70,6 +70,7 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl private readonly SemaphoreSlim _operationLock = new(1, 1); private Session _session; private QueuedSeek _queuedSeek; + private QueuedSeek _runningSeek; private bool _isScrubbing; private bool _resumeAfterScrub; private bool _isDisposed; @@ -238,14 +239,20 @@ await RunOperationAsync(null, "Playback error", async _ => public Task SeekAsync(TimeSpan position) => QueueSeek(position, accurate: true); /// - /// Seeks relative to where playback is headed: a seek still waiting to run counts as the starting point, so holding an arrow key moves five seconds per press instead of collapsing onto the stale on-screen position. + /// Seeks relative to where playback is headed: a seek still waiting to run or still landing counts as the starting point, so holding an arrow key moves five seconds per press instead of collapsing onto the stale on-screen position. /// public Task SeekByAsync(TimeSpan offset) { - var origin = _queuedSeek is { } queued && ReferenceEquals(queued.Session, _session) ? queued.Target : Position; + var origin = PendingSeekOf(_queuedSeek) ?? PendingSeekOf(_runningSeek) ?? Position; return QueueSeek(Clamp(origin + offset, TimeSpan.Zero, Duration), accurate: true); } + /// + /// Where is headed, when it belongs to the open clip. + /// + private TimeSpan? PendingSeekOf(QueuedSeek seek) => + seek is not null && ReferenceEquals(seek.Session, _session) ? seek.Target : null; + /// /// Like but jumps to the nearest keyframe, which is far cheaper. /// Intended to be called repeatedly while the seek bar thumb is being dragged, so the video keeps up in near-real-time. @@ -626,7 +633,7 @@ private Task QueueSeek(TimeSpan target, bool accurate) var seek = new QueuedSeek(_session, target, accurate); _queuedSeek = seek; - seek.Completion = RunOperationAsync(_session, "Seek error", _ => + seek.Completion = RunOperationAsync(_session, "Seek error", async _ => { // From here on a new request queues a fresh seek instead of retargeting this one. if (ReferenceEquals(_queuedSeek, seek)) @@ -634,7 +641,16 @@ private Task QueueSeek(TimeSpan target, bool accurate) _queuedSeek = null; } - return RepositionAsync(seek.Target, seek.Accurate); + // Relative seeks start from this target until it lands, because Position only moves once every camera has landed and a held key repeats sooner than that. + _runningSeek = seek; + try + { + await RepositionAsync(seek.Target, seek.Accurate); + } + finally + { + _runningSeek = null; + } }); if (ReferenceEquals(_queuedSeek, seek) && seek.Completion.IsCompleted) From 07f6d029f4cac8a08a5ad9dead1bc299dee67b26 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Tue, 6 Oct 2026 00:32:46 -0500 Subject: [PATCH 17/73] Say why a clip's front video can't be played A clip whose front file was empty, truncated, held open by another program, or moved away after the scan always showed "No front camera footage found.", even with the file sitting right there. A locked file is only a temporary problem, but nothing said so, and pressing Play after the lock was released played the clip under the old error. The builder drops every chunk whose front file has no readable duration, whatever the reason, and the controller turned that into one generic message. The controller now looks at the clip's front files when none could be played. A file another program holds open says so and suggests closing that program and pressing Play; a file that opens but can't be read says it may be damaged or incomplete; files that are gone suggest a rescan; and a clip with no front files keeps the old message. When Play then opens the clip, the stale playback error is cleared instead of covering the video. Found in v1.1.0 local testing: F18. --- .../MainWindowViewModelTests.Playback.cs | 25 +++++++ .../VideoPlayerControllerTests.cs | 56 ++++++++++++++- SentryDeck/Playback/VideoPlayerController.cs | 71 +++++++++++++++++-- SentryDeck/ViewModels/PlaybackViewModel.cs | 5 ++ 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index e0dc00b..456d268 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -163,6 +163,31 @@ public void ControllerError_ShowsErrorOverlay() vm.Error.Details.ShouldBe("decode failed"); } + [Fact] + public void ControllerError_ClearedByARetry_HidesErrorOverlay() + { + // Pressing Play reopens a clip whose file was locked; once it opens, the old error must not cover the video that now plays. + var vm = CreateViewModelWithController(out var controller, out _); + controller.ErrorMessage = "The front camera video can't be read because another program is using it."; + + controller.ErrorMessage = null; + + vm.Error.IsVisible.ShouldBeFalse(); + } + + [Fact] + public void ControllerError_ClearedWhileAnotherNoticeShows_KeepsThatNotice() + { + var vm = CreateViewModelWithController(out var controller, out _); + controller.ErrorMessage = "decode failed"; + vm.Error.Show("Delete Failed", "Could not delete clip"); + + controller.ErrorMessage = null; + + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Delete Failed"); + } + [Fact] public void CanGoNextPrevious_ReflectControllerPlaylist() { diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index 8c992e1..43d6d09 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -192,7 +192,7 @@ public async Task SelectingClip_WhenAllFilesAreEncrypted_ExplainsTheEncryptionTo } [Fact] - public async Task SelectingClip_WhenAllFilesAreTruncated_ReportsNoFootageRatherThanEncryption() + public async Task SelectingClip_WhenAllFilesAreTruncated_ReportsDamagedFileRatherThanEncryption() { // Same all-unreadable shape, but the files still carry MP4 headers (truncated writes): that's ordinary corruption and must NOT be blamed on encryption. using var playlists = new TestPlaylistDirectory(); @@ -205,7 +205,59 @@ public async Task SelectingClip_WhenAllFilesAreTruncated_ReportsNoFootageRatherT await rig.OpenAsync(); - rig.Controller.ErrorMessage.ShouldBe("No front camera footage found."); + rig.Controller.ErrorMessage.ShouldBe("The front camera video can't be read. The file may be damaged or incomplete."); + rig.Controller.IsPlaying.ShouldBeFalse(); + } + + [Fact] + public async Task SelectingClip_WhenFrontFileIsEmpty_ReportsDamagedFile() + { + // The file is right there in the folder, so "no footage found" would send the user hunting for footage they can already see. + using var playlists = new TestPlaylistDirectory(); + using var rig = new Rig(chunkCount: 1, builder: playlists.CreateBuilder()); + File.WriteAllBytes(rig.Files.GetPath(0, CameraNames.Front), []); + + await rig.OpenAsync(); + + rig.Controller.ErrorMessage.ShouldBe("The front camera video can't be read. The file may be damaged or incomplete."); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.All.ShouldAllBe(player => player.OpenedPaths.Count == 0); + } + + [Fact] + public async Task SelectingClip_WhenFrontFileIsLocked_SaysAnotherProgramHasItAndPlaysOnceReleased() + { + // A lock is temporary, unlike damage: the user needs to know the clip will play once the other program lets go. + using var playlists = new TestPlaylistDirectory(); + using var rig = new Rig(chunkCount: 1, builder: playlists.CreateBuilder()); + + using (new FileStream(rig.Files.GetPath(0, CameraNames.Front), FileMode.Open, FileAccess.Read, FileShare.None)) + { + await rig.OpenAsync(); + } + + rig.Controller.ErrorMessage.ShouldBe("The front camera video can't be read because another program is using it. Close that program, then press Play to try again."); + rig.Controller.IsPlaying.ShouldBeFalse(); + + await rig.Controller.PlayAsync(); + await rig.Controller.WhenIdleAsync(); + + rig.Controller.ErrorMessage.ShouldBeNull(); + rig.Controller.IsPlaying.ShouldBeTrue(); + rig.Front.IsPlaying.ShouldBeTrue(); + } + + [Fact] + public async Task SelectingClip_WhenClipFolderWasMovedAway_SaysTheFootageCantBeFound() + { + // The list still shows a clip whose folder was moved or deleted after the scan; only a rescan brings the list back in step with the disk. + using var playlists = new TestPlaylistDirectory(); + using var rig = new Rig(chunkCount: 2, builder: playlists.CreateBuilder()); + Directory.Delete(rig.Files.RootPath, recursive: true); + + await rig.OpenAsync(); + + rig.Controller.ErrorMessage.ShouldBe("The front camera video can't be found. The clip may have been moved or deleted; rescan the folder to update the list."); rig.Controller.IsPlaying.ShouldBeFalse(); } diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 8ea9b54..62cecd8 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -489,16 +489,20 @@ private async Task OpenSourceAsync(Session session, ClipMediaSource mediaSource, if (!mediaSource.CameraPlaylistPaths.ContainsKey(_primaryCamera)) { + // A fully encrypted clip lands here too: the builder finds no readable moov in any front file and excludes every chunk. + // Telling these apart means opening the clip's files, which can stall on a slow or disconnected drive, so it runs off the UI thread like the build. + var reason = await Task.Run( + () => EncryptedClipDetector.LooksEncrypted(clip) ? EncryptedClipMessage : DescribeUnplayablePrimary(clip), + token); + token.ThrowIfCancellationRequested(); + ErrorMessage = reason; + Log.Warning( - "Cannot open clip because the primary camera is missing. PrimaryCamera={PrimaryCamera}; ClipName={ClipName}; Cameras={Cameras}", + "Cannot open clip because the primary camera is missing. PrimaryCamera={PrimaryCamera}; ClipName={ClipName}; Cameras={Cameras}; Reason={Reason}", _primaryCamera, clip.Name, - mediaSource.CameraPlaylistPaths.Keys.Order().ToArray()); - - // A fully encrypted clip lands here too: the builder finds no readable moov in any front file and excludes every chunk. - ErrorMessage = EncryptedClipDetector.LooksEncrypted(clip) - ? EncryptedClipMessage - : $"No {CameraNames.DisplayName(_primaryCamera)} camera footage found."; + mediaSource.CameraPlaylistPaths.Keys.Order().ToArray(), + ErrorMessage); return; } @@ -553,6 +557,59 @@ private async Task OpenSourceAsync(Session session, ClipMediaSource mediaSource, results.Where(result => result.Opened).Select(result => result.Key).Order().ToArray()); } + /// + /// Says why a clip has no primary-camera footage to play. + /// The builder drops a missing, locked, or damaged file alike, but each needs something different from the user: a locked file plays as soon as the other program lets go of it, and a moved clip needs a rescan. + /// + private string DescribeUnplayablePrimary(CamClip clip) + { + var camera = CameraNames.DisplayName(_primaryCamera); + var paths = clip.Chunks + .Select(chunk => chunk.Files.GetValueOrDefault(_primaryCamera)?.FullPath) + .OfType() + .ToList(); + + if (paths.Count == 0) + { + return $"No {camera} camera footage found."; + } + + if (paths.Any(IsInUseByAnotherProgram)) + { + return $"The {camera} camera video can't be read because another program is using it. Close that program, then press Play to try again."; + } + + if (paths.Any(File.Exists)) + { + return $"The {camera} camera video can't be read. The file may be damaged or incomplete."; + } + + return $"The {camera} camera video can't be found. The clip may have been moved or deleted; rescan the folder to update the list."; + } + + /// + /// True when another program holds open without sharing it. + /// Opens it the way the duration probe does, so a file this reports as free is one the probe could open too. + /// + private static bool IsInUseByAnotherProgram(string path) + { + const int ErrorSharingViolation = 32; + + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return false; + } + catch (IOException ex) when ((ex.HResult & 0xFFFF) == ErrorSharingViolation) + { + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + private async Task OpenCameraAsync(string camera, ICameraPlayer player, ClipMediaSource mediaSource) { if (!mediaSource.CameraPlaylistPaths.TryGetValue(camera, out var playlistPath) || !File.Exists(playlistPath)) diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index e3e5a75..5d331ec 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -630,6 +630,11 @@ private void HandlePlayerControllerPropertyChanged(string propertyName) { _error.Show("Playback Error", _playerController.ErrorMessage); } + else if (_error.IsVisible && _error.Title == "Playback Error") + { + // Play retries a clip that failed to open (one whose file another program had locked, say), so the old failure must not stay over the video that now plays. + _error.Clear(); + } break; From e93b86fc58c8b4f04ec86c148cef72c53c809282 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Tue, 6 Oct 2026 04:14:09 -0500 Subject: [PATCH 18/73] Keep the side cameras from freezing after a seek near their end At 2x and faster, sending a clip to its end (a seek-bar click while paused, or Right while playing) and pressing Play could still leave cameras frozen, often the left and right ones, on that clip and every clip after it until the app restarted. The end-of-footage margin from the earlier fix didn't help, because the seek wasn't looking at every frame. Flyleaf thins decoding for fast playback: faster than about 1.7x its video decoder keeps only one frame in every speed * fps / 61 (one in ten at 16x) and drops the rest. A paused seek decodes through that same path, so near the end of a camera's footage it could drop every frame left after the target. Flyleaf then marks the decoder ended while its thread is parked, and nothing ever wakes that thread again. Away from the end, the same thinning made paused seeks at fast speeds land up to a quarter of a second late. Each Flyleaf player now decodes at real time whenever it seeks or steps a frame while paused, and only takes the chosen speed when it starts playing. As a safety net for any other way a paused seek could run out of frames, a decoder thread that Flyleaf left parked behind a finished status is woken before the camera plays and before its clip closes. On 18 real clips at 16x, seeking to the end and pressing Play froze at least one camera in 20 of 36 runs before this change and in none after it, and the next clip played on every camera each time. Found in v1.1.0 local testing: F52. --- SentryDeck.Tests/PlaybackSpeedGateTests.cs | 79 ++++++++++++++++++++ SentryDeck/Playback/FlyleafCameraPlayer.cs | 62 ++++++++++++++- SentryDeck/Playback/PlaybackSpeedGate.cs | 62 +++++++++++++++ SentryDeck/Playback/VideoPlayerController.cs | 1 + 4 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 SentryDeck.Tests/PlaybackSpeedGateTests.cs create mode 100644 SentryDeck/Playback/PlaybackSpeedGate.cs diff --git a/SentryDeck.Tests/PlaybackSpeedGateTests.cs b/SentryDeck.Tests/PlaybackSpeedGateTests.cs new file mode 100644 index 0000000..eec25b5 --- /dev/null +++ b/SentryDeck.Tests/PlaybackSpeedGateTests.cs @@ -0,0 +1,79 @@ +namespace SentryDeck.Tests; + +public sealed class PlaybackSpeedGateTests +{ + private bool _isPlaying; + private double _decoderSpeed = 1.0; + + private PlaybackSpeedGate CreateGate() => new(() => _isPlaying, speed => _decoderSpeed = speed); + + [Fact] + public void ChosenSpeed_ChangedWhilePlaying_ReachesTheDecoderAtOnce() + { + var gate = CreateGate(); + _isPlaying = true; + + gate.ChosenSpeed = 4.0; + + _decoderSpeed.ShouldBe(4.0); + } + + [Fact] + public void ChosenSpeed_ChangedWhilePaused_LeavesThePausedDecoderAtRealTime() + { + var gate = CreateGate(); + + gate.ChosenSpeed = 16.0; + + _decoderSpeed.ShouldBe(1.0); + gate.ChosenSpeed.ShouldBe(16.0); + } + + [Fact] + public void BeforePausedDecode_AfterFastPlayback_DropsTheDecoderToRealTime() + { + var gate = CreateGate(); + gate.ChosenSpeed = 16.0; + gate.Play(() => _isPlaying = true); + _isPlaying = false; + + gate.BeforePausedDecode(); + + _decoderSpeed.ShouldBe(1.0); + gate.ChosenSpeed.ShouldBe(16.0); + } + + [Fact] + public void Play_AfterAPausedDecode_StartsAtTheChosenSpeed() + { + var gate = CreateGate(); + gate.ChosenSpeed = 16.0; + gate.BeforePausedDecode(); + var speedWhenPlaybackStarted = 0.0; + + gate.Play(() => + { + speedWhenPlaybackStarted = _decoderSpeed; + _isPlaying = true; + }); + + speedWhenPlaybackStarted.ShouldBe(16.0); + _decoderSpeed.ShouldBe(16.0); + } + + [Fact] + public void Play_SpeedChosenWhilePlaybackStarts_ReachesTheDecoder() + { + var gate = CreateGate(); + gate.ChosenSpeed = 2.0; + + gate.Play(() => + { + // The player hasn't reported playing yet, so the gate holds this change back. + gate.ChosenSpeed = 8.0; + _isPlaying = true; + }); + + _decoderSpeed.ShouldBe(8.0); + } +} diff --git a/SentryDeck/Playback/FlyleafCameraPlayer.cs b/SentryDeck/Playback/FlyleafCameraPlayer.cs index fdc0d70..0b36549 100644 --- a/SentryDeck/Playback/FlyleafCameraPlayer.cs +++ b/SentryDeck/Playback/FlyleafCameraPlayer.cs @@ -3,8 +3,10 @@ using System.Windows.Threading; using FlyleafLib; using FlyleafLib.Controls.WPF; +using FlyleafLib.MediaFramework.MediaDecoder; using FlyleafLib.MediaPlayer; using Serilog; +using DecoderStatus = FlyleafLib.MediaFramework.Status; namespace SentryDeck; @@ -47,8 +49,15 @@ internal sealed class FlyleafCameraPlayer : ICameraPlayer /// private static readonly SemaphoreSlim RendererResetGate = new(1, 1); + /// + /// How long a video decoder thread gets to finish on its own before treats it as stranded. + /// A thread on its way out exits within milliseconds; a stranded one waits forever. + /// + private static readonly TimeSpan StrandedDecoderGrace = TimeSpan.FromMilliseconds(100); + private readonly FlyleafHost _host; private readonly Player _player; + private readonly PlaybackSpeedGate _speedGate; private readonly Dispatcher _dispatcher; private readonly SemaphoreSlim _commandGate = new(1, 1); private TaskCompletionSource _pendingSeek; @@ -67,6 +76,7 @@ public FlyleafCameraPlayer(FlyleafHost host) _host = host ?? throw new ArgumentNullException(nameof(host)); _dispatcher = host.Dispatcher; _player = new Player(CreateConfig()); + _speedGate = new PlaybackSpeedGate(() => _player.IsPlaying, speed => _player.Speed = speed); // All shortcuts are app-wide and act on every camera at once; Flyleaf's default bindings (space, arrows, …) would pause/seek only the player whose surface has focus. // Must run AFTER the Player ctor: KeysConfig.SetPlayer force-loads the defaults into any empty binding list, so clearing the config up front is undone. @@ -91,12 +101,12 @@ public FlyleafCameraPlayer(FlyleafHost host) public double Speed { - get => _player.Speed; + get => _speedGate.ChosenSpeed; set { if (value > 0) { - _player.Speed = value; + _speedGate.ChosenSpeed = value; } } } @@ -140,7 +150,8 @@ public Task PlayAsync() => RunCommandAsync(() => { if (_isOpen) { - _player.Play(); + WakeStrandedDecoder(); + _speedGate.Play(_player.Play); } }); @@ -166,7 +177,12 @@ public async Task CloseAsync() await RendererResetGate.WaitAsync(); try { - await RunCommandAsync(_player.Stop); + await RunCommandAsync(() => + { + // Stop leaves a stranded decoder thread waiting, which would then come back to life inside the next clip. + WakeStrandedDecoder(); + _player.Stop(); + }); } finally { @@ -201,6 +217,9 @@ public async Task StepFrameAsync(bool forward) return; } + // At fast speeds a forward step would otherwise skip the frames Flyleaf thins out of fast playback. + _speedGate.BeforePausedDecode(); + if (forward) { await Task.Run(_player.ShowFrameNext); @@ -351,6 +370,8 @@ private async Task SeekCoreAsync(TimeSpan position, bool accurate) var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (awaitCompletion) { + // A paused seek decodes on Flyleaf's seek thread, and at fast speeds it would see only the frames fast playback keeps. + _speedGate.BeforePausedDecode(); _pendingSeek = completion; } @@ -380,6 +401,39 @@ private async Task SeekCoreAsync(TimeSpan position, bool accurate) } } + /// + /// Wakes a video decoder thread that Flyleaf has left waiting behind a finished status, the frozen-camera state that keeps seeks out of. + /// This is the safety net for any other way a paused decode might run out of frames. + /// + /// + /// A paused seek decodes on Flyleaf's seek thread while the decoder thread waits to be resumed. + /// When that decode runs out of frames, Flyleaf marks the decoder Ended (a later flush turns it into Stopped) without stopping the waiting thread. + /// From then on Start sees a live thread and leaves it alone, and Stop sees a finished decoder and does nothing, so the camera stays frozen on every clip until the app restarts. + /// A decoder thread that still claims to be finished but is alive after can only be that waiting thread. + /// Marking it paused again tells Flyleaf the truth: Start then resumes it and Stop ends it, as they would for any paused decoder. + /// + private void WakeStrandedDecoder() + { + var decoder = _player.VideoDecoder; + if (!LooksStranded(decoder)) + { + return; + } + + Thread.Sleep(StrandedDecoderGrace); + if (!LooksStranded(decoder)) + { + return; + } + + Log.Warning("Waking a video decoder thread Flyleaf left waiting. DecoderStatus={DecoderStatus}; Position={Position}", decoder.Status, Position); + decoder.Status = DecoderStatus.Paused; + } + + // IsRunning means the decoder thread is alive and not paused. + private static bool LooksStranded(VideoDecoder decoder) => + decoder is { IsRunning: true, Status: DecoderStatus.Ended or DecoderStatus.Stopped }; + private void OnSeekCompleted(object sender, int milliseconds) { // Flyleaf raises -1 for an intermediate or failed step; only a real landing position ends the wait. diff --git a/SentryDeck/Playback/PlaybackSpeedGate.cs b/SentryDeck/Playback/PlaybackSpeedGate.cs new file mode 100644 index 0000000..0797d36 --- /dev/null +++ b/SentryDeck/Playback/PlaybackSpeedGate.cs @@ -0,0 +1,62 @@ +namespace SentryDeck; + +/// +/// Gives a camera's decoder the chosen playback speed only while it plays, and real time whenever it decodes while paused (a seek or a frame step). +/// +/// +/// Faster than about 1.7x on Tesla's 36 fps footage, Flyleaf's video decoder keeps only one frame in every speed * fps / 61 (its 60 fps output cap plus one) and drops the rest, so fast playback doesn't decode frames nobody would see. +/// A paused seek and a frame step decode through that same path, so at 16x a seek only sees about one frame in ten and lands up to a quarter of a second past its target. +/// Near the end of a camera's footage it can drop every frame left after the target. +/// Flyleaf then marks the decoder ended while its thread is still parked, nothing ever wakes that thread again, and the camera stays frozen on every clip until the app restarts. +/// +internal sealed class PlaybackSpeedGate +{ + private readonly Func _isPlaying; + private readonly Action _applySpeed; + private double _chosenSpeed = 1.0; + + /// Whether the player is playing right now. + /// Sets the speed the player's decoder runs at. + public PlaybackSpeedGate(Func isPlaying, Action applySpeed) + { + _isPlaying = isPlaying ?? throw new ArgumentNullException(nameof(isPlaying)); + _applySpeed = applySpeed ?? throw new ArgumentNullException(nameof(applySpeed)); + } + + /// + /// The speed playback runs at. + /// A change while paused waits for the next , so it can't reach a seek that is still decoding. + /// + public double ChosenSpeed + { + get => Volatile.Read(ref _chosenSpeed); + set + { + Volatile.Write(ref _chosenSpeed, value); + + if (_isPlaying()) + { + _applySpeed(value); + } + } + } + + /// + /// Drops the decoder to real time before a paused seek or frame step, so it decodes every frame and lands on the one asked for. + /// + public void BeforePausedDecode() => _applySpeed(1.0); + + /// + /// Starts playback through at the chosen speed. + /// + public void Play(Action play) + { + ArgumentNullException.ThrowIfNull(play); + + _applySpeed(ChosenSpeed); + play(); + + // A speed chosen while playback was starting still found the player paused, so it was left for here. + _applySpeed(ChosenSpeed); + } +} diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 62cecd8..80fda88 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -60,6 +60,7 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl /// An accurate Flyleaf seek past a stream's last frame finds no frame to show and leaves that camera's decoder thread parked for good, so the camera stays frozen on every clip until the app restarts. /// Tesla files show their last frame up to about 60 ms before the duration they record (the final sample falls outside the file's edit list), so one frame of margin is not enough. /// A tenth of a second clears every file measured with room to spare, and stays well inside so play still replays a clip sent to its end. + /// That only holds for a seek that decodes every frame, which is why the Flyleaf player seeks at real time even when playback is fast (see ). /// internal static readonly TimeSpan EndSeekMargin = TimeSpan.FromMilliseconds(100); From a4d1fc1659d7af5b50372f30155d9260e111d5ba Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:26:09 -0500 Subject: [PATCH 19/73] Warn when a clip delete is permanent, and never half-delete a clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Delete to Recycle Bin" always promised a recoverable delete, but the shell only recycles when it can. On a network share, a removable drive such as a TeslaCam USB stick, or a clip whose camera file paths reach 260 characters, it deleted the clip permanently instead, without a word, because the delete call suppresses the shell's own permanent-delete warning. That permanent delete also goes one file at a time, so a file held open by another program stopped it partway: the other cameras, event.json and thumb.png were already gone, the app reported "The operation was canceled", and the gutted clip stayed in the list. The delete now checks first whether Windows can recycle the folder (a network path or drive, a removable or other non-fixed drive, a path of MAX_PATH or more, or SHQueryRecycleBin failing). When it can't, the confirmation asks "Permanently delete this clip?" with the reason and defaults to No; if the check itself fails, nothing is deleted. After playback stops and before anything is deleted, every file in the folder must open for exclusive access; if one is in use, nothing is deleted and the overlay names the file. The menu item now reads "Delete clip…", because only the confirmation can tell whether a clip will go to the Recycle Bin. Found in v1.1.0 local testing: F96, and the new locked-file data loss in cluster K. --- .../MainWindowViewModelTests.Clips.cs | 141 +++++++++++++++++- SentryDeck.Tests/RecycleBinTests.cs | 73 +++++++++ SentryDeck/MainWindow.xaml | 7 +- SentryDeck/Services/RecycleBin.cs | 119 +++++++++++++++ SentryDeck/ViewModels/ClipLibraryViewModel.cs | 90 +++++++++-- 5 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 SentryDeck.Tests/RecycleBinTests.cs create mode 100644 SentryDeck/Services/RecycleBin.cs diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 83b5e76..1e94b5d 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -242,6 +242,9 @@ private static async Task LoadedViewModelAsync(IReadOnlyLis { var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); await vm.Library.LoadClipsAsync(new[] { "root" }); + + // Whether a drive has a Recycle Bin depends on the machine running the tests, so it is pinned here and varied only where a test is about it. + vm.Library.WhyCannotRecycle = _ => null; return vm; } @@ -260,7 +263,7 @@ public async Task DeleteClip_Confirmed_RecyclesFolder_AndRemovesFromList() var clips = ClipsWithDistinctPaths(3); var vm = await LoadedViewModelAsync(clips); string recycledPath = null; - vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.ConfirmDeleteClip = (_, _) => true; vm.Library.RecycleClipFolder = path => recycledPath = path; var target = vm.Library.FilteredClips.Single(clip => clip.Name == "Clip 1"); @@ -277,7 +280,7 @@ public async Task DeleteClip_Cancelled_KeepsClip_AndDoesNotRecycle() var clips = ClipsWithDistinctPaths(2); var vm = await LoadedViewModelAsync(clips); var recycleCalls = 0; - vm.Library.ConfirmDeleteClip = _ => false; + vm.Library.ConfirmDeleteClip = (_, _) => false; vm.Library.RecycleClipFolder = _ => recycleCalls++; var target = vm.Library.FilteredClips[0]; @@ -293,7 +296,7 @@ public async Task DeleteClip_TheSelectedClip_ClearsSelectionAndNowPlaying() { var clips = ClipsWithDistinctPaths(2); var vm = await LoadedViewModelAsync(clips); - vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.ConfirmDeleteClip = (_, _) => true; vm.Library.RecycleClipFolder = _ => { }; var target = vm.Library.FilteredClips[0]; @@ -312,7 +315,7 @@ public async Task DeleteClip_NotTheSelectedClip_LeavesSelectionIntact() { var clips = ClipsWithDistinctPaths(3); var vm = await LoadedViewModelAsync(clips); - vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.ConfirmDeleteClip = (_, _) => true; vm.Library.RecycleClipFolder = _ => { }; var selected = vm.Library.FilteredClips.Single(clip => clip.Name == "Clip 2"); @@ -331,7 +334,7 @@ public async Task DeleteClip_WhenRecycleFails_ShowsError_AndKeepsClip() { var clips = ClipsWithDistinctPaths(2); var vm = await LoadedViewModelAsync(clips); - vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.ConfirmDeleteClip = (_, _) => true; vm.Library.RecycleClipFolder = _ => throw new IOException("The file is in use."); var target = vm.Library.FilteredClips[0]; @@ -343,6 +346,106 @@ public async Task DeleteClip_WhenRecycleFails_ShowsError_AndKeepsClip() vm.Library.FilteredClips.ShouldContain(target); } + [Fact] + public async Task DeleteClip_FolderCannotBeRecycled_WarnsThatTheDeleteIsPermanent() + { + var clips = ClipsWithDistinctPaths(2); + var vm = await LoadedViewModelAsync(clips); + string prompt = null; + vm.Library.WhyCannotRecycle = _ => RecycleBin.RemovableReason; + vm.Library.ConfirmDeleteClip = (clip, whyPermanent) => + { + prompt = ClipLibraryViewModel.DeleteClipPrompt(clip, whyPermanent); + return false; + }; + + await vm.Library.DeleteClipCommand.ExecuteAsync(vm.Library.FilteredClips[0]); + + // A TeslaCam USB flash drive has no Recycle Bin, so a prompt promising one let the shell destroy footage the user believed was recoverable. + prompt.ShouldStartWith("Permanently delete this clip?"); + prompt.ShouldContain(RecycleBin.RemovableReason); + prompt.ShouldNotContain("Move this clip to the Recycle Bin"); + } + + [Fact] + public async Task DeleteClip_FolderCanBeRecycled_OffersTheRecycleBin() + { + var clips = ClipsWithDistinctPaths(2); + var vm = await LoadedViewModelAsync(clips); + string prompt = null; + vm.Library.ConfirmDeleteClip = (clip, whyPermanent) => + { + prompt = ClipLibraryViewModel.DeleteClipPrompt(clip, whyPermanent); + return false; + }; + + await vm.Library.DeleteClipCommand.ExecuteAsync(vm.Library.FilteredClips[0]); + + prompt.ShouldStartWith("Move this clip to the Recycle Bin?"); + prompt.ShouldNotContain("Permanently"); + } + + [Fact] + public async Task DeleteClip_RecycleCheckFails_DeletesNothing() + { + var clips = ClipsWithDistinctPaths(2); + var vm = await LoadedViewModelAsync(clips); + var confirmCalls = 0; + var recycleCalls = 0; + vm.Library.WhyCannotRecycle = _ => throw new IOException("The network name is no longer available."); + vm.Library.ConfirmDeleteClip = (_, _) => + { + confirmCalls++; + return true; + }; + vm.Library.RecycleClipFolder = _ => recycleCalls++; + + var target = vm.Library.FilteredClips[0]; + await vm.Library.DeleteClipCommand.ExecuteAsync(target); + + // Without an answer, any prompt could promise a recovery that won't happen, so the delete stops before asking. + confirmCalls.ShouldBe(0); + recycleCalls.ShouldBe(0); + vm.Error.Title.ShouldBe("Delete Failed"); + vm.Error.Details.ShouldContain("Nothing was deleted"); + vm.Library.FilteredClips.ShouldContain(target); + } + + [Fact] + public async Task DeleteClip_AFileIsHeldOpen_DeletesNothing() + { + using var folder = new TempDirectory(); + var back = folder.Write("2025-01-01_12-00-00-back.mp4", [1]); + var front = folder.Write("2025-01-01_12-00-00-front.mp4", [1]); + var clip = new CamClip(folder.Path, "Clip", new DateTime(2025, 1, 1, 12, 0, 0), [], camEvent: null); + var vm = await LoadedViewModelAsync([clip]); + vm.Library.ConfirmDeleteClip = (_, _) => true; + + // Like the shell on a drive without a Recycle Bin: files go one at a time, so a file in use stops the delete partway through. + vm.Library.RecycleClipFolder = path => + { + foreach (var file in Directory.GetFiles(path).Order(StringComparer.OrdinalIgnoreCase)) + { + File.Delete(file); + } + + Directory.Delete(path); + }; + + // Shared reading without shared deleting, the way another video player holds a file it is showing. + using (new FileStream(front, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + await vm.Library.DeleteClipCommand.ExecuteAsync(clip); + } + + // Deleting the other cameras before reaching the open one left a gutted clip that still listed and played, front camera only. + File.Exists(back).ShouldBeTrue(); + File.Exists(front).ShouldBeTrue(); + vm.Library.FilteredClips.ShouldContain(clip); + vm.Error.Title.ShouldBe("Clip In Use"); + vm.Error.Details.ShouldContain(Path.GetFileName(front)); + } + // --- Deleting the clip that is actually open: the point of the feature, and the only path that touches the player. // These drive a real controller, and the recycle runs behind a Task.Run whose continuation lands off the test thread -- hence the uiInvoker seam instead of the dispatcher hop. --- @@ -353,7 +456,8 @@ public async Task DeleteClip_TheOpenClip_StopsPlaybackBeforeRecycling() var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); var closesBeforeDelete = front.Count("close"); var closesWhenRecycled = -1; - vm.Library.ConfirmDeleteClip = _ => true; + vm.Library.WhyCannotRecycle = _ => null; + vm.Library.ConfirmDeleteClip = (_, _) => true; vm.Library.RecycleClipFolder = _ => closesWhenRecycled = front.Count("close"); vm.Playback.SeekPosition = 0.5; @@ -364,13 +468,36 @@ public async Task DeleteClip_TheOpenClip_StopsPlaybackBeforeRecycling() vm.Playback.SeekPosition.ShouldBe(0); } + [Fact] + public async Task DeleteClip_TheOpenClip_ChecksForFilesInUseAfterStoppingPlayback() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, _, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); + var closesBeforeDelete = front.Count("close"); + var closesWhenChecked = -1; + vm.Library.WhyCannotRecycle = _ => null; + vm.Library.ConfirmDeleteClip = (_, _) => true; + vm.Library.FindFileInUse = _ => + { + closesWhenChecked = front.Count("close"); + return null; + }; + vm.Library.RecycleClipFolder = _ => { }; + + await vm.Library.DeleteClipCommand.ExecuteAsync(clipFiles.Clip); + + // The app's own players hold the open clip's files, so checking before they close would always report the clip as in use. + closesWhenChecked.ShouldBeGreaterThan(closesBeforeDelete); + } + [Fact] 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.Library.ConfirmDeleteClip = _ => true; + vm.Library.WhyCannotRecycle = _ => null; + vm.Library.ConfirmDeleteClip = (_, _) => true; vm.Library.RecycleClipFolder = _ => { }; vm.Library.SelectedClip = clip; // sets NowPlayingClip too (see OnSelectedClipChanged) diff --git a/SentryDeck.Tests/RecycleBinTests.cs b/SentryDeck.Tests/RecycleBinTests.cs new file mode 100644 index 0000000..23d0538 --- /dev/null +++ b/SentryDeck.Tests/RecycleBinTests.cs @@ -0,0 +1,73 @@ +using System.IO; + +namespace SentryDeck.Tests; + +public sealed class RecycleBinTests +{ + [Fact] + public void WhyCannotRecycle_NetworkShare_SaysItIsOnTheNetwork() + { + // A network share has no Recycle Bin, and the shell deleted clips there permanently while the prompt promised a recoverable delete. + RecycleBin.WhyCannotRecycle(@"\\nas\TeslaCam\SavedClips\2025-01-01_12-00-00").ShouldBe(RecycleBin.NetworkReason); + } + + [Fact] + public void WhyCannotRecycle_AFilePathOfMaxPathLength_SaysThePathsAreTooLong() + { + using var folder = new TempDirectory(); + WriteFileWithPathLength(folder, RecycleBin.MaxPathLength); + + // The shell permanently deleted clips whose camera file paths reached 260 characters, on an ordinary local drive. + RecycleBin.WhyCannotRecycle(folder.Path).ShouldBe(RecycleBin.PathTooLongReason); + } + + [Fact] + public void WhyCannotRecycle_AFilePathJustUnderMaxPathLength_DoesNotBlameThePaths() + { + using var folder = new TempDirectory(); + WriteFileWithPathLength(folder, RecycleBin.MaxPathLength - 1); + + RecycleBin.WhyCannotRecycle(folder.Path).ShouldNotBe(RecycleBin.PathTooLongReason); + } + + [Fact] + public void FindFileInUse_AFileHeldOpen_ReturnsIt() + { + using var folder = new TempDirectory(); + folder.Write("2025-01-01_12-00-00-back.mp4", [1]); + var front = folder.Write("2025-01-01_12-00-00-front.mp4", [1]); + + // Shared reading without shared deleting, the way another video player holds a file it is showing. + using var held = new FileStream(front, FileMode.Open, FileAccess.Read, FileShare.Read); + + RecycleBin.FindFileInUse(folder.Path).ShouldBe(front); + } + + [Fact] + public void FindFileInUse_NoFileHeldOpen_ReturnsNull() + { + using var folder = new TempDirectory(); + folder.Write("2025-01-01_12-00-00-back.mp4", [1]); + folder.Write("2025-01-01_12-00-00-front.mp4", [1]); + + RecycleBin.FindFileInUse(folder.Path).ShouldBeNull(); + } + + [Fact] + public void FindFileInUse_FolderAlreadyGone_ReturnsNull() + { + var folder = new TempDirectory(); + folder.Dispose(); + + RecycleBin.FindFileInUse(folder.Path).ShouldBeNull(); + } + + private static void WriteFileWithPathLength(TempDirectory folder, int length) + { + const string extension = ".mp4"; + var prefixLength = folder.Path.Length + 1; + var name = new string('x', length - prefixLength - extension.Length) + extension; + var path = folder.Write(name); + path.Length.ShouldBe(length); + } +} diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index 19db48b..0648461 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -534,8 +534,11 @@ - - + diff --git a/SentryDeck/Services/RecycleBin.cs b/SentryDeck/Services/RecycleBin.cs new file mode 100644 index 0000000..1f8d5e6 --- /dev/null +++ b/SentryDeck/Services/RecycleBin.cs @@ -0,0 +1,119 @@ +using System.IO; +using System.Runtime.InteropServices; + +namespace SentryDeck; + +/// +/// Checks a clip folder before it is deleted, because the shell only reveals the answers by destroying footage. +/// When it can't recycle a folder, it deletes it permanently without asking. +/// That permanent delete goes one file at a time, so a file held open stops it partway through and leaves a gutted clip behind. +/// +internal static class RecycleBin +{ + /// + /// The shell can't recycle a path of MAX_PATH characters or more. + /// Measured on Windows 11: a folder holding a 259-character file path went to the Recycle Bin, and one holding a 260-character path was deleted permanently. + /// + internal const int MaxPathLength = 260; + + internal const string NetworkReason = "it's on a network location"; + internal const string RemovableReason = "it's on a removable drive"; + internal const string NoRecycleBinReason = "this drive has no Recycle Bin"; + internal const string PathTooLongReason = "its file paths are too long for the Recycle Bin"; + + /// + /// Says why the shell would permanently delete this folder instead of recycling it, or returns null when it can be recycled. + /// + public static string WhyCannotRecycle(string folderPath) + { + var fullPath = Path.GetFullPath(folderPath); + var root = Path.GetPathRoot(fullPath); + if (root.StartsWith(@"\\", StringComparison.Ordinal)) + { + return NetworkReason; + } + + // Windows keeps a Recycle Bin only on fixed drives, and a TeslaCam USB flash drive reports itself as removable. + switch (new DriveInfo(root).DriveType) + { + case DriveType.Fixed: + break; + case DriveType.Network: + return NetworkReason; + case DriveType.Removable: + return RemovableReason; + default: + return NoRecycleBinReason; + } + + if (LongestPathLength(fullPath) >= MaxPathLength) + { + return PathTooLongReason; + } + + return HasRecycleBin(root) ? null : NoRecycleBinReason; + } + + /// + /// Returns the first file in the folder that is open elsewhere, or null when every file can be deleted. + /// + public static string FindFileInUse(string folderPath) + { + // A folder that is already gone holds no open files; the delete itself reports that it's missing. + if (!Directory.Exists(folderPath)) + { + return null; + } + + foreach (var file in Directory.EnumerateFiles(folderPath, "*", SearchOption.AllDirectories)) + { + try + { + // Exclusive access fails while any other handle is open, which is exactly when a permanent delete would stop partway through. + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.None); + } + catch (IOException) + { + return file; + } + } + + return null; + } + + private static int LongestPathLength(string folderPath) + { + var longest = folderPath.Length; + if (!Directory.Exists(folderPath)) + { + return longest; + } + + foreach (var entry in Directory.EnumerateFileSystemEntries(folderPath, "*", SearchOption.AllDirectories)) + { + longest = Math.Max(longest, entry.Length); + } + + return longest; + } + + // A fixed drive the shell can't query for a Recycle Bin gets a permanent delete too. + private static bool HasRecycleBin(string root) + { + var info = new ShQueryRBInfo { Size = Marshal.SizeOf() }; + return SHQueryRecycleBin(root, ref info) == 0; + } + + // The natural layout matches the 64-bit shell header. + // A 32-bit process would fail the size check and treat every drive as having no Recycle Bin, which over-warns but never deletes silently. + [StructLayout(LayoutKind.Sequential)] + private struct ShQueryRBInfo + { + public int Size; + public long TotalBytes; + public long ItemCount; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, EntryPoint = "SHQueryRecycleBinW")] + private static extern int SHQueryRecycleBin(string rootPath, ref ShQueryRBInfo info); +} diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index b97346e..a4b5022 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -75,24 +75,44 @@ public ClipLibraryViewModel( private bool _isLoadingClips; /// - /// Asks the user to confirm sending a clip to the Recycle Bin. + /// Asks the user to confirm deleting a clip. + /// The second argument is why Windows can't recycle the clip's folder, or null when it can, so the prompt never promises a Recycle Bin the delete won't use. /// Returns true to proceed. - /// Overridable for tests; defaults to a yes/no message box. + /// Overridable for tests; defaults to a yes/no message box that defaults to No for a permanent delete. /// - internal Func ConfirmDeleteClip { get; set; } = clip => + internal Func ConfirmDeleteClip { get; set; } = (clip, whyPermanent) => MessageBox.Show( - $"Move this clip to the Recycle Bin?\n\n{clip.Name}\n{clip.FullPath}", - "Delete clip", + DeleteClipPrompt(clip, whyPermanent), + whyPermanent is null ? "Delete clip" : "Permanently delete clip", MessageBoxButton.YesNo, - MessageBoxImage.Warning) == MessageBoxResult.Yes; + MessageBoxImage.Warning, + whyPermanent is null ? MessageBoxResult.Yes : MessageBoxResult.No) == 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. + /// Says why the shell would permanently delete a clip folder instead of recycling it, or returns null when it can recycle it. + /// Overridable for tests, so they don't depend on the drives of the machine running them. + /// + internal Func WhyCannotRecycle { get; set; } = RecycleBin.WhyCannotRecycle; + + /// + /// Finds a file in a clip folder that is open elsewhere, or returns null when none is. + /// Overridable for tests. + /// + internal Func FindFileInUse { get; set; } = RecycleBin.FindFileInUse; + + /// + /// Sends a clip folder to the Windows Recycle Bin. + /// When Windows can't recycle it, the shell deletes it permanently without asking, which is why the delete command checks before confirming. + /// Overridable for tests; defaults to the shell recycle operation. /// internal Action RecycleClipFolder { get; set; } = path => FileSystem.DeleteDirectory(path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin); + internal static string DeleteClipPrompt(CamClip clip, string whyPermanent) => + whyPermanent is null + ? $"Move this clip to the Recycle Bin?\n\n{clip.Name}\n{clip.FullPath}" + : $"Permanently delete this clip?\n\nIt can't go to the Recycle Bin because {whyPermanent}, so it can't be recovered once deleted.\n\n{clip.Name}\n{clip.FullPath}"; + /// Rescans the current source of dashcam roots (auto-discovered drives, or the folders the user picked). public Task ReloadAsync(TimeSpan minimumLoadingDuration = default) => LoadClipsAsync(_rootSource(), minimumLoadingDuration); @@ -336,12 +356,31 @@ 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. + /// Confirms first, warning when Windows would delete the folder permanently instead. + /// If the clip is the one currently open, it then stops playback so Flyleaf releases its file handles before the shell tries to delete the (otherwise locked) folder. /// [RelayCommand(CanExecute = nameof(CanUseClip))] private async Task DeleteClipAsync(CamClip clip) { - if (clip is null || !ConfirmDeleteClip(clip)) + if (clip is null) + { + return; + } + + string whyPermanent; + try + { + whyPermanent = await Task.Run(() => WhyCannotRecycle(clip.FullPath)); + } + catch (Exception ex) + { + // Without knowing whether Windows would recycle the folder, any prompt could promise a recovery that won't happen. + Log.Error(ex, "Could not check whether a clip can be recycled. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); + _error.Show("Delete Failed", $"Could not delete clip: {clip.Name}\n\nNothing was deleted, because Windows couldn't tell whether it can go to the Recycle Bin.\n\nError: {ex.Message}"); + return; + } + + if (!ConfirmDeleteClip(clip, whyPermanent)) { return; } @@ -356,10 +395,30 @@ private async Task DeleteClipAsync(CamClip clip) _playback.SeekPosition = 0; } + string fileInUse; try { - Log.Information("Deleting clip to Recycle Bin. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); - await Task.Run(() => RecycleClipFolder(clip.FullPath)); + // A permanent delete removes files one at a time and stops at the first one in use, so check them all first and delete nothing unless every file can go. + fileInUse = await Task.Run(() => + { + var inUse = FindFileInUse(clip.FullPath); + if (inUse is not null) + { + return inUse; + } + + if (whyPermanent is null) + { + Log.Information("Deleting clip to Recycle Bin. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); + } + else + { + Log.Information("Permanently deleting clip, because it can't be recycled. ClipName={ClipName}; ClipPath={ClipPath}; Reason={Reason}", clip.Name, clip.FullPath, whyPermanent); + } + + RecycleClipFolder(clip.FullPath); + return null; + }); } catch (Exception ex) { @@ -368,6 +427,13 @@ private async Task DeleteClipAsync(CamClip clip) return; } + if (fileInUse is not null) + { + Log.Warning("Did not delete clip, because a file is in use. ClipName={ClipName}; FilePath={FilePath}", clip.Name, fileInUse); + _error.Show("Clip In Use", $"Nothing was deleted, because a file in this clip is in use:\n{Path.GetFileName(fileInUse)}\n\nClose any program that has it open, then try again."); + return; + } + // Drop it from the sidebar list and keep the player's Next/Previous playlist in sync. _allClips.Remove(clip); _playback.RemoveFromPlaylist(clip); From 0945c51513ada252b9ac7829b368960c73e62dbd Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:30:42 -0500 Subject: [PATCH 20/73] Continue Next and Previous from a deleted clip's place in the list After deleting the clip that was playing, Previous clip was disabled and Next clip opened the oldest clip in the library, which can be years away at the bottom of the list. Removing the open clip from the playlist reset its position to "nothing selected", and from there Next starts at the first clip and there is nothing before it. The playlist now remembers the slot the deleted clip held, so Next opens the clip that followed it and Previous the clip before it, the same neighbours the user would have reached without the delete. Selecting a clip or loading a new list clears that remembered place. Found in v1.1.0 local testing: F97. --- SentryDeck.Data/Playback/ClipPlaylist.cs | 27 ++++- SentryDeck.Tests/ClipPlaylistTests.cs | 100 ++++++++++++++++++ .../MainWindowViewModelTests.Clips.cs | 28 +++++ 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/SentryDeck.Data/Playback/ClipPlaylist.cs b/SentryDeck.Data/Playback/ClipPlaylist.cs index ea6975c..c18b748 100644 --- a/SentryDeck.Data/Playback/ClipPlaylist.cs +++ b/SentryDeck.Data/Playback/ClipPlaylist.cs @@ -10,15 +10,24 @@ public sealed class ClipPlaylist private readonly List _clips = []; private int _currentIndex = -1; + // The slot the current clip held before it was removed, or -1. + // Without it, Next after deleting the open clip would restart from the first (oldest) clip and Previous would be disabled, losing the user's place in the list. + private int _removedCurrentIndex = -1; + public IReadOnlyList Clips => _clips; public CamClip CurrentClip => IsValidIndex(_currentIndex) ? _clips[_currentIndex] : null; public int CurrentIndex => _currentIndex; - public bool HasPrevious => _currentIndex > 0; + public bool HasPrevious => IsValidIndex(PreviousIndex); + + public bool HasNext => IsValidIndex(NextIndex); + + // The clip that followed a removed current clip slid into its slot, so that slot is next and the one before it is previous. + private int NextIndex => _removedCurrentIndex >= 0 ? _removedCurrentIndex : _currentIndex + 1; - public bool HasNext => _currentIndex < _clips.Count - 1; + private int PreviousIndex => _removedCurrentIndex >= 0 ? _removedCurrentIndex - 1 : _currentIndex - 1; public event EventHandler CurrentClipChanged; public event EventHandler PlaylistChanged; @@ -28,6 +37,7 @@ public void SetClips(IEnumerable clips) _clips.Clear(); _clips.AddRange(clips ?? []); _currentIndex = -1; + _removedCurrentIndex = -1; Log.Debug("Playlist set. ClipCount={ClipCount}", _clips.Count); PlaylistChanged?.Invoke(this, EventArgs.Empty); @@ -36,12 +46,12 @@ public void SetClips(IEnumerable clips) public bool MoveNext() { - return MoveTo(_currentIndex + 1); + return MoveTo(NextIndex); } public bool MovePrevious() { - return MoveTo(_currentIndex - 1); + return MoveTo(PreviousIndex); } public bool MoveTo(CamClip clip) @@ -57,13 +67,15 @@ public bool MoveTo(int index) } _currentIndex = index; + _removedCurrentIndex = -1; CurrentClipChanged?.Invoke(this, CurrentClip); 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. + /// Removing the current clip itself clears the selection (the caller owns stopping playback) but remembers its place, so Next and Previous continue from its neighbours. + /// A clip before the current one (or before the remembered place) shifts the index down to compensate. /// Returns false when the clip isn't in the playlist. /// public bool RemoveClip(CamClip clip) @@ -83,6 +95,11 @@ public bool RemoveClip(CamClip clip) else if (index == _currentIndex) { _currentIndex = -1; + _removedCurrentIndex = index; + } + else if (index < _removedCurrentIndex) + { + _removedCurrentIndex--; } Log.Debug("Removed clip from playlist. ClipName={ClipName}; ClipCount={ClipCount}", clip.Name, _clips.Count); diff --git a/SentryDeck.Tests/ClipPlaylistTests.cs b/SentryDeck.Tests/ClipPlaylistTests.cs index cf03763..26a765e 100644 --- a/SentryDeck.Tests/ClipPlaylistTests.cs +++ b/SentryDeck.Tests/ClipPlaylistTests.cs @@ -147,6 +147,106 @@ public void RemoveClip_TheCurrentClip_ClearsSelection() playlist.Clips.ShouldBe(new[] { clips[0], clips[2] }); } + [Fact] + public void RemoveClip_TheCurrentClip_NextOpensTheClipThatFollowedIt() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(4); + playlist.SetClips(clips); + playlist.MoveTo(1); + + playlist.RemoveClip(clips[1]); + + // Watching a clip, deleting it, then pressing Next is a triage loop; restarting from the first clip would lose the user's place in the library. + playlist.HasNext.ShouldBeTrue(); + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[2]); + } + + [Fact] + public void RemoveClip_TheCurrentClip_PreviousOpensTheClipBeforeIt() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(4); + playlist.SetClips(clips); + playlist.MoveTo(2); + + playlist.RemoveClip(clips[2]); + + playlist.HasPrevious.ShouldBeTrue(); + playlist.MovePrevious().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[1]); + } + + [Fact] + public void RemoveClip_TheCurrentClipAtTheEnd_HasNoNextButKeepsPrevious() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + playlist.MoveTo(2); + + playlist.RemoveClip(clips[2]); + + playlist.HasNext.ShouldBeFalse(); + playlist.MoveNext().ShouldBeFalse(); + playlist.HasPrevious.ShouldBeTrue(); + playlist.MovePrevious().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[1]); + } + + [Fact] + public void RemoveClip_TheCurrentClipAtTheStart_HasNoPreviousButKeepsNext() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + playlist.MoveTo(0); + + playlist.RemoveClip(clips[0]); + + playlist.HasPrevious.ShouldBeFalse(); + playlist.MovePrevious().ShouldBeFalse(); + playlist.HasNext.ShouldBeTrue(); + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[1]); + } + + [Fact] + public void RemoveClip_AnEarlierClipAfterTheCurrentWasRemoved_KeepsThePlace() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(4); + playlist.SetClips(clips); + playlist.MoveTo(2); + playlist.RemoveClip(clips[2]); + + // Deleting a second clip from earlier in the list shifts every later clip down, so the remembered place has to shift with them. + playlist.RemoveClip(clips[0]); + + playlist.Clips.ShouldBe(new[] { clips[1], clips[3] }); + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[3]); + } + + [Fact] + public void SetClips_AfterTheCurrentClipWasRemoved_NextStartsFromTheFirstClip() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + playlist.MoveTo(2); + playlist.RemoveClip(clips[2]); + var reloaded = TestClips.Create(3); + + // A rescan replaces the list, so a place remembered from the old list means nothing in the new one. + playlist.SetClips(reloaded); + + playlist.HasPrevious.ShouldBeFalse(); + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(reloaded[0]); + } + [Fact] public void RemoveClip_NotInPlaylist_ReturnsFalse_AndChangesNothing() { diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 1e94b5d..add681d 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -509,6 +509,34 @@ public async Task DeleteClip_TheOpenClip_RemovesItFromThePlayerPlaylist() vm.Library.SelectedClip.ShouldBeNull(); } + [Fact] + public async Task DeleteClip_TheOpenClip_NextOpensTheClipAfterIt() + { + using var older = TestClipFiles.Create(chunkCount: 1); + using var deleted = TestClipFiles.Create(chunkCount: 1); + using var newer = TestClipFiles.Create(chunkCount: 1); + var controller = BuildFourCameraController(new FakeCameraPlayer()); + controller.LoadClips([older.Clip, deleted.Clip, newer.Clip]); + controller.Playlist.MoveTo(deleted.Clip); + RunPinnedToTestThread(controller.WhenIdleAsync); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + vm.Library.WhyCannotRecycle = _ => null; + vm.Library.ConfirmDeleteClip = (_, _) => true; + vm.Library.RecycleClipFolder = _ => { }; + vm.Library.SelectedClip = deleted.Clip; + + await vm.Library.DeleteClipCommand.ExecuteAsync(deleted.Clip); + + // Delete then Next is how a user works through footage; jumping to the oldest clip in the library would lose their place. + vm.Playback.CanGoPrevious.ShouldBeTrue(); + vm.Playback.CanGoNext.ShouldBeTrue(); + await vm.Playback.NextCommand.ExecuteAsync(null); + await controller.WhenIdleAsync(); + controller.CurrentClip.ShouldBe(newer.Clip); + vm.Library.SelectedClip.ShouldBe(newer.Clip); + } + [Fact] public async Task FilteredClips_NoMatch_IsEmpty() { From 5a98c2f7dadb3e8c559b67f66e6c31c32b43c8ca Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:41:09 -0500 Subject: [PATCH 21/73] Keep the player's place when a delete doesn't happen, and drop gone clips When a delete of the open clip didn't go ahead (a file in use, a shell error, or Cancel or Skip in the shell's own dialog), the player stayed stopped and black at 0:00 with seeking disabled. Playback is closed before the delete so Windows can remove the files, and nothing reopened it afterwards. Pressing Cancel or Skip was also reported as "Delete Failed: The operation was canceled.", because the shell delete throws OperationCanceledException for both. Deleting a clip whose folder had already been removed outside the app showed a raw "Could not find directory" error over the video and left the ghost clip in the list. Now the open clip is reopened at the position it had: paused when a notice covers the video, and playing again after a Cancel, which shows no error. A folder that is already gone counts as deleted, so its clip just leaves the list. VideoPlayerController gains ReopenAsync, which opens a stopped clip at a given position instead of at the event, and does nothing if another clip has been opened since. Found in v1.1.0 local testing: F98, F99. --- .../MainWindowViewModelTests.Clips.cs | 85 +++++++++++++++++++ .../VideoPlayerControllerTests.cs | 55 ++++++++++++ SentryDeck/Playback/VideoPlayerController.cs | 21 ++++- SentryDeck/ViewModels/ClipLibraryViewModel.cs | 38 ++++++++- SentryDeck/ViewModels/PlaybackViewModel.cs | 7 ++ 5 files changed, 201 insertions(+), 5 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index add681d..dba6505 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -446,6 +446,42 @@ public async Task DeleteClip_AFileIsHeldOpen_DeletesNothing() vm.Error.Details.ShouldContain(Path.GetFileName(front)); } + [Fact] + public async Task DeleteClip_CanceledInTheShellDialog_KeepsClipWithoutAnError() + { + var clips = ClipsWithDistinctPaths(2); + var vm = await LoadedViewModelAsync(clips); + vm.Library.ConfirmDeleteClip = (_, _) => true; + + // What the shell delete throws when the user picks Cancel or Skip in its own error dialog. + vm.Library.RecycleClipFolder = _ => throw new OperationCanceledException(); + + var target = vm.Library.FilteredClips[0]; + await vm.Library.DeleteClipCommand.ExecuteAsync(target); + + // The user chose not to delete it, so "Delete Failed: The operation was canceled." misreported their choice and covered the video. + vm.Error.IsVisible.ShouldBeFalse(); + vm.Library.FilteredClips.ShouldContain(target); + } + + [Fact] + public async Task DeleteClip_FolderAlreadyGone_RemovesClipWithoutAnError() + { + using var folder = new TempDirectory(); + var gone = new CamClip(folder.Path, "Gone", new DateTime(2025, 1, 1, 12, 0, 0), [], camEvent: null); + var kept = ClipsWithDistinctPaths(1)[0]; + var vm = await LoadedViewModelAsync([gone, kept]); + vm.Library.ConfirmDeleteClip = (_, _) => true; + + // Removed outside the app after the scan; the delete runs the real shell call, which reports the missing folder before it shows any UI. + Directory.Delete(folder.Path); + await vm.Library.DeleteClipCommand.ExecuteAsync(gone); + + vm.Error.IsVisible.ShouldBeFalse(); + vm.Library.FilteredClips.ShouldNotContain(gone); + vm.Library.FilteredClips.ShouldContain(kept); + } + // --- Deleting the clip that is actually open: the point of the feature, and the only path that touches the player. // These drive a real controller, and the recycle runs behind a Task.Run whose continuation lands off the test thread -- hence the uiInvoker seam instead of the dispatcher hop. --- @@ -490,6 +526,55 @@ public async Task DeleteClip_TheOpenClip_ChecksForFilesInUseAfterStoppingPlaybac closesWhenChecked.ShouldBeGreaterThan(closesBeforeDelete); } + [Fact] + public async Task DeleteClip_TheOpenClipIsInUse_ReopensItPausedWhereItWas() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, controller, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); + PlayFrom(controller, front, TimeSpan.FromSeconds(25)); + vm.Library.WhyCannotRecycle = _ => null; + vm.Library.ConfirmDeleteClip = (_, _) => true; + vm.Library.FindFileInUse = _ => clipFiles.GetPath(0, CameraNames.Back); + vm.Library.RecycleClipFolder = _ => { }; + + await vm.Library.DeleteClipCommand.ExecuteAsync(clipFiles.Clip); + + // Nothing was deleted, so leaving the player stopped and black at 0:00 lost the user's place for nothing. + vm.Error.Title.ShouldBe("Clip In Use"); + controller.IsMediaOpen.ShouldBeTrue(); + controller.Position.TotalSeconds.ShouldBe(25, 0.01); + vm.Playback.SeekPosition.ShouldBe(25.0 / 60, 0.001); + + // The notice covers the video, so playing on behind it would skip footage the user never sees. + controller.IsPlaying.ShouldBeFalse(); + } + + [Fact] + public async Task DeleteClip_TheOpenClipCanceledInTheShellDialog_KeepsPlayingWhereItWas() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, controller, front) = CreateViewModelWithOpenedClip(clipFiles.Clip, uiInvoker: action => action()); + PlayFrom(controller, front, TimeSpan.FromSeconds(25)); + vm.Library.WhyCannotRecycle = _ => null; + vm.Library.ConfirmDeleteClip = (_, _) => true; + vm.Library.RecycleClipFolder = _ => throw new OperationCanceledException(); + + await vm.Library.DeleteClipCommand.ExecuteAsync(clipFiles.Clip); + + vm.Error.IsVisible.ShouldBeFalse(); + controller.IsPlaying.ShouldBeTrue(); + controller.Position.TotalSeconds.ShouldBe(25, 0.01); + vm.Playback.SeekPosition.ShouldBe(25.0 / 60, 0.001); + } + + // The view-model subscribes after the clip opened and started, so a pause and resume lets it see playback the way it would in the app. + private static void PlayFrom(VideoPlayerController controller, FakeCameraPlayer front, TimeSpan position) + { + RunPinnedToTestThread(controller.PauseAsync); + RunPinnedToTestThread(controller.PlayAsync); + front.RaisePositionChanged(position); + } + [Fact] public async Task DeleteClip_TheOpenClip_RemovesItFromThePlayerPlaylist() { diff --git a/SentryDeck.Tests/VideoPlayerControllerTests.cs b/SentryDeck.Tests/VideoPlayerControllerTests.cs index 43d6d09..631e84d 100644 --- a/SentryDeck.Tests/VideoPlayerControllerTests.cs +++ b/SentryDeck.Tests/VideoPlayerControllerTests.cs @@ -536,6 +536,61 @@ public async Task PlayAsync_WhileTheClipIsStillOpening_DoesNotRestartTheOpen() rig.Controller.IsPlaying.ShouldBeTrue(); } + [Fact] + public async Task ReopenAsync_AfterStop_OpensEveryCameraAtThePositionPaused() + { + // The event maps to media time 90s, so a plain open would start at 80s. + using var rig = new Rig(chunkCount: 3); + var clip = WithEvent(rig.Clip, rig.Clip.Chunks[1].Timestamp.AddSeconds(30)); + await rig.OpenAsync(clip); + await rig.Controller.StopAsync(); + var playsBeforeReopen = rig.Front.Count("play"); + + await rig.Controller.ReopenAsync(clip, TimeSpan.FromSeconds(125), play: false); + + // A delete that didn't happen reopens the clip this way, and jumping back to the event would lose the user's place. + rig.All.ShouldAllBe(player => player.IsOpen && player.Position == TimeSpan.FromSeconds(125)); + rig.Controller.Position.ShouldBe(TimeSpan.FromSeconds(125)); + rig.Controller.IsMediaOpen.ShouldBeTrue(); + rig.Controller.IsPlaying.ShouldBeFalse(); + rig.Front.Count("play").ShouldBe(playsBeforeReopen); + } + + [Fact] + public async Task ReopenAsync_AfterAnotherClipOpened_LeavesThatClipPlaying() + { + using var rig = new Rig(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + await rig.OpenAsync(rig.Clip, secondFiles.Clip); + await rig.Controller.StopAsync(); + await rig.Controller.NextAsync(); + await rig.Controller.WhenIdleAsync(); + + await rig.Controller.ReopenAsync(rig.Clip, TimeSpan.FromSeconds(30), play: false); + await rig.Controller.WhenIdleAsync(); + + // The user moved on while the stopped clip waited, so a late reopen must not take the player back. + rig.Controller.CurrentClip.ShouldBe(secondFiles.Clip); + rig.Front.OpenedPaths[^1].ShouldStartWith(secondFiles.RootPath); + rig.Controller.IsPlaying.ShouldBeTrue(); + } + + [Fact] + public async Task ReopenAsync_AfterTheSameClipWasPlayedAgain_LeavesItPlaying() + { + using var rig = new Rig(); + await rig.OpenAsync(); + await rig.Controller.StopAsync(); + await rig.Controller.PlayAsync(); + + await rig.Controller.ReopenAsync(rig.Clip, TimeSpan.FromSeconds(30), play: false); + await rig.Controller.WhenIdleAsync(); + + // The shell's delete dialog doesn't block the app, so the user can press Play before it closes, and a late reopen would pause what they just started. + rig.Front.OpenedPaths.Count.ShouldBe(2); + rig.Controller.IsPlaying.ShouldBeTrue(); + } + [Fact] public async Task StopAsync_WhenOneCameraFailsToClose_StillClosesTheRestAndResets() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 80fda88..39bc0de 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -359,6 +359,19 @@ public Task GoToClipAsync(int index) return Task.CompletedTask; } + /// + /// Opens again at , after closed it to release its files for something that then didn't happen. + /// A plain open starts at the event instead, which would lose the user's place. + /// Does nothing once anything has been opened since the stop, so a late call can't take the player back from what the user moved on to. + /// + public Task ReopenAsync(CamClip clip, TimeSpan position, bool play) + { + if (_session is not null || clip is null || !ReferenceEquals(CurrentClip, clip)) + return Task.CompletedTask; + + return OpenClipAsync(clip, position, play); + } + public async Task LoadClipsAsync(IEnumerable clips) { await StopAsync(); @@ -411,7 +424,7 @@ public void Dispose() } } - private async Task OpenClipAsync(CamClip clip) + private async Task OpenClipAsync(CamClip clip, TimeSpan? startPosition = null, bool play = true) { _session?.Cancel(); var session = new Session(clip); @@ -426,7 +439,7 @@ await RunOperationAsync(session, "Playback error", async token => { try { - await OpenClipCoreAsync(session, token); + await OpenClipCoreAsync(session, startPosition, play, token); } finally { @@ -438,7 +451,7 @@ await RunOperationAsync(session, "Playback error", async token => }); } - private async Task OpenClipCoreAsync(Session session, CancellationToken token) + private async Task OpenClipCoreAsync(Session session, TimeSpan? startPosition, bool play, CancellationToken token) { var clip = session.Clip; @@ -463,7 +476,7 @@ private async Task OpenClipCoreAsync(Session session, CancellationToken token) clip.Chunks.Count, mediaSource.Duration); - await OpenSourceAsync(session, mediaSource, ResolveEventStartPosition(clip, mediaSource), play: true, token); + await OpenSourceAsync(session, mediaSource, startPosition ?? ResolveEventStartPosition(clip, mediaSource), play, token); } /// diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index a4b5022..9ab97c4 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -358,6 +358,7 @@ 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, warning when Windows would delete the folder permanently instead. /// If the clip is the one currently open, it then stops playback so Flyleaf releases its file handles before the shell tries to delete the (otherwise locked) folder. + /// When the delete doesn't happen, the clip is reopened where it was. /// [RelayCommand(CanExecute = nameof(CanUseClip))] private async Task DeleteClipAsync(CamClip clip) @@ -389,12 +390,26 @@ private async Task DeleteClipAsync(CamClip clip) var isCurrent = ReferenceEquals(SelectedClip, clip) || ReferenceEquals(_playback.NowPlayingClip, clip) || _playback.CurrentClip == clip; + + // Where the open clip was, so a delete that doesn't happen puts the user back there instead of on a stopped, black player at 0:00. + TimeSpan? reopenAt = null; + var wasPlaying = false; if (isCurrent && _playback.HasPlayer) { + if (_playback.CanSeek && ReferenceEquals(_playback.CurrentClip, clip)) + { + reopenAt = _playback.Duration * _playback.SeekPosition; + wasPlaying = _playback.IsPlaying; + } + await _playback.StopPlayerAsync(); _playback.SeekPosition = 0; } + Task ReopenAsync(bool play) => reopenAt is { } position + ? _playback.ReopenAsync(clip, position, play) + : Task.CompletedTask; + string fileInUse; try { @@ -416,14 +431,34 @@ private async Task DeleteClipAsync(CamClip clip) Log.Information("Permanently deleting clip, because it can't be recycled. ClipName={ClipName}; ClipPath={ClipPath}; Reason={Reason}", clip.Name, clip.FullPath, whyPermanent); } - RecycleClipFolder(clip.FullPath); + try + { + RecycleClipFolder(clip.FullPath); + } + catch (DirectoryNotFoundException) when (!Directory.Exists(clip.FullPath)) + { + // Removed outside the app since the last scan, so there is nothing left to delete and only the list is out of date. + // Checked here rather than in a filter on the UI thread, because asking a network share that has gone offline can stall for seconds. + Log.Information("Clip folder is already gone, so it is only removed from the list. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); + } + return null; }); } + catch (OperationCanceledException) + { + // The shell throws this when the user picks Cancel or Skip in its own error dialog, so they already know the clip is still there. + Log.Information("Clip delete was canceled in the shell dialog. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); + await ReopenAsync(play: wasPlaying); + return; + } catch (Exception ex) { Log.Error(ex, "Failed to delete clip. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); _error.Show("Delete Failed", $"Could not delete clip: {clip.Name}\n\nError: {ex.Message}"); + + // Paused, because the notice covers the video and the clip would otherwise play on unseen behind it. + await ReopenAsync(play: false); return; } @@ -431,6 +466,7 @@ private async Task DeleteClipAsync(CamClip clip) { Log.Warning("Did not delete clip, because a file is in use. ClipName={ClipName}; FilePath={FilePath}", clip.Name, fileInUse); _error.Show("Clip In Use", $"Nothing was deleted, because a file in this clip is in use:\n{Path.GetFileName(fileInUse)}\n\nClose any program that has it open, then try again."); + await ReopenAsync(play: false); return; } diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index 5d331ec..ee88f07 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -243,6 +243,13 @@ public void RemoveFromPlaylist(CamClip clip) /// Closes whatever the player has open, releasing its file handles. public Task StopPlayerAsync() => _playerController?.StopAsync() ?? Task.CompletedTask; + /// + /// Puts back in the player at after released its files for a change that didn't happen. + /// Unlike selecting it, this keeps the user's place and camera instead of jumping to the event. + /// + public Task ReopenAsync(CamClip clip, TimeSpan position, bool play) => + _playerController?.ReopenAsync(clip, position, play) ?? Task.CompletedTask; + public Task TogglePlayPauseAsync() => _playerController?.TogglePlayPauseAsync() ?? Task.CompletedTask; public Task StepFrameAsync(bool forward) => _playerController?.StepFrameAsync(forward) ?? Task.CompletedTask; From d758afbb358d6c41df985cab5071d03073d9c1d5 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:44:00 -0500 Subject: [PATCH 22/73] Copy the Tesla folder name and an ISO 8601 timestamp from the clip menu "Copy clip name" and "Copy timestamp" both copied the same text, such as "12/16/2025 15:53:27". The clip name is the folder's date reformatted for display, and the timestamp used the invariant culture's US month-first date, so neither gave the name Tesla and the drive use for the clip, and a date like 03/04/2025 reads as April 3 in day-first countries. "Copy clip name" now copies the clip's folder name (for example 2025-12-16_15-53-27), falling back to the display name for a clip read from the root of a drive. "Copy timestamp" copies a year-first ISO 8601 timestamp (2025-12-16 15:53:27). The copy commands now go through an overridable clipboard action so tests can check what each one copies. Found in v1.1.0 local testing: F101. --- .../MainWindowViewModelTests.Clips.cs | 59 +++++++++++++++++++ SentryDeck/ViewModels/ClipLibraryViewModel.cs | 22 +++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index dba6505..485fe78 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -156,6 +156,65 @@ public void ShowOnMap_DisabledWithoutCoordinates() vm.Library.ShowOnMapCommand.CanExecute(withLocation).ShouldBeTrue(); } + // --- Copy commands: the injectable clipboard shows what each one copies --- + + // A clip as CamClip.Map builds it from a Tesla folder: the display name is the folder's date reformatted, so it differs from the folder name. + private static CamClip TeslaFolderClip(string path) => + new(path, "12/16/2025 15:53:27", new DateTime(2025, 12, 16, 15, 53, 27), [], camEvent: null); + + private static string Copied(ClipLibraryViewModel library, IRelayCommand command, CamClip clip) + { + string copied = null; + library.CopyToClipboard = text => copied = text; + command.Execute(clip); + return copied; + } + + [Fact] + public void CopyClipName_TeslaClip_CopiesTheFolderName() + { + var vm = CreateViewModel(); + var clip = TeslaFolderClip(@"C:\TeslaCam\SavedClips\2025-12-16_15-53-27"); + + Copied(vm.Library, vm.Library.CopyClipNameCommand, clip).ShouldBe("2025-12-16_15-53-27"); + } + + [Fact] + public void CopyClipName_PathEndsWithASeparator_CopiesTheFolderName() + { + var vm = CreateViewModel(); + var clip = TeslaFolderClip(@"C:\TeslaCam\SavedClips\2025-12-16_15-53-27\"); + + Copied(vm.Library, vm.Library.CopyClipNameCommand, clip).ShouldBe("2025-12-16_15-53-27"); + } + + [Fact] + public void CopyClipName_ClipAtADriveRoot_CopiesTheDisplayName() + { + var vm = CreateViewModel(); + var clip = TeslaFolderClip(@"E:\"); + + Copied(vm.Library, vm.Library.CopyClipNameCommand, clip).ShouldBe("12/16/2025 15:53:27"); + } + + [Fact] + public void CopyTimestamp_AnyClip_CopiesAnIso8601Timestamp() + { + var vm = CreateViewModel(); + var clip = TeslaFolderClip(@"C:\TeslaCam\SavedClips\2025-12-16_15-53-27"); + + Copied(vm.Library, vm.Library.CopyTimestampCommand, clip).ShouldBe("2025-12-16 15:53:27"); + } + + [Fact] + public void CopyClipPath_AnyClip_CopiesTheFolderPath() + { + var vm = CreateViewModel(); + var clip = TeslaFolderClip(@"C:\TeslaCam\SavedClips\2025-12-16_15-53-27"); + + Copied(vm.Library, vm.Library.CopyClipPathCommand, clip).ShouldBe(@"C:\TeslaCam\SavedClips\2025-12-16_15-53-27"); + } + // --- Scanning: what the sidebar and the overlay show when there is nothing to scan, or a root can't be read. // The overlay is the whole UI in these states, so its wording and its dismissibility are the behavior. --- diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index 9ab97c4..8ac06bd 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -326,12 +326,18 @@ private void OpenClipFolder(CamClip clip) }); } + /// + /// Puts text on the Windows clipboard. + /// Overridable for tests, so they can check what a copy command copies without touching the real clipboard. + /// + internal Action CopyToClipboard { get; set; } = Clipboard.SetText; + [RelayCommand(CanExecute = nameof(CanUseClip))] private void CopyClipPath(CamClip clip) { if (clip is not null) { - Clipboard.SetText(clip.FullPath); + CopyToClipboard(clip.FullPath); } } @@ -340,7 +346,7 @@ private void CopyClipName(CamClip clip) { if (clip is not null) { - Clipboard.SetText(clip.Name); + CopyToClipboard(ClipFolderName(clip)); } } @@ -349,11 +355,19 @@ private void CopyTimestamp(CamClip clip) { if (clip is not null) { - // Invariant (24-hour, culture-stable) so the copied value matches the clip name and is paste-searchable, unlike the ambiguous AM/PM current-culture rendering. - Clipboard.SetText(clip.Timestamp.ToString(CultureInfo.InvariantCulture)); + // ISO 8601 puts the year first, so a pasted date can't be misread the way a month-first 03/04/2025 is in day-first countries. + CopyToClipboard(clip.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); } } + // The clip's own folder name, which is how Tesla, the drive, and other tools identify the clip, unlike the reformatted date in its display name. + // A clip read from the root of a drive has no folder name, so it falls back to the display name rather than copying nothing. + private static string ClipFolderName(CamClip clip) + { + var folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(clip.FullPath)); + return string.IsNullOrEmpty(folderName) ? clip.Name : folderName; + } + /// /// Sends the clip's folder to the Recycle Bin so the timeline can be tidied without leaving the app. /// Confirms first, warning when Windows would delete the folder permanently instead. From cf61d9212a7a42e653f9a5d822a8adc9452448c3 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:17:25 -0500 Subject: [PATCH 23/73] Make Next and Previous follow the list when several folders are loaded With more than one folder loaded, Next and Previous went folder by folder instead of by date, so they jumped around the list and Next could be disabled partway through it. Clips sharing a timestamp made them zig-zag up and down the list even with one folder, and a folder picked together with a folder inside it listed every clip in the overlap twice. The player's playlist was each folder's clips concatenated in pick order, while the list sorted everything by date with a tie-break the playlist didn't mirror. The merged clips are now sorted once in the list's order (newest first, then name, then path), the list shows that order, and the playlist is its exact reverse. Repeated folders and folders inside another picked folder are scanned only once. Found in v1.1.0 local testing: F41, F33, and the tied-timestamp zig-zag (new in cluster E). --- .../MainWindowViewModelTests.Clips.cs | 83 +++++++++++++++++++ SentryDeck/ViewModels/ClipLibraryViewModel.cs | 68 +++++++++++++-- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 485fe78..2817d4c 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -285,6 +285,89 @@ public async Task LoadClips_OneRootFails_KeepsClipsFromTheHealthyRoot() vm.Error.Title.ShouldBe("Error Loading Clips"); } + // --- Next/Previous order: the player's playlist must be the list's exact reverse, since Next and Previous step through it by index. --- + + private static CamClip ClipAt(string folder, string name, DateTime timestamp) => + new(folder, name, timestamp, [], camEvent: null); + + // Wires a real controller so the playlist Next/Previous walk can be inspected. + // Each root is mapped the way the app maps it (CamStorage sorts its own clips), so the test sees the same per-root order the real scan produces. + private (MainWindowViewModel Vm, VideoPlayerController Controller) CreateViewModelWithRoots(Dictionary> clipsByRoot) + { + var controller = BuildFourCameraController(new FakeCameraPlayer()); + var vm = new MainWindowViewModel( + () => controller, + clipLoader: root => new CamStorage(root, clipsByRoot[root]).Clips, + backgroundYield: () => Task.CompletedTask, + uiInvoker: action => action()); + vm.InitializePlayer(); + return (vm, controller); + } + + [Fact] + public async Task LoadClips_SeveralFolders_PlaylistFollowsTheDateOrderOfTheList() + { + var (vm, controller) = CreateViewModelWithRoots(new() + { + [@"D:\TeslaCam"] = + [ + ClipAt(@"D:\TeslaCam\a", "2024-04-01", new DateTime(2024, 4, 1)), + ClipAt(@"D:\TeslaCam\b", "2025-05-10", new DateTime(2025, 5, 10)), + ], + [@"E:\TeslaCam"] = [ClipAt(@"E:\TeslaCam\c", "2025-04-11", new DateTime(2025, 4, 11))], + }); + + await vm.Library.LoadClipsAsync([@"D:\TeslaCam", @"E:\TeslaCam"]); + + // Concatenating the folders in pick order made Next from 2025-05-10 jump back to 2025-04-11 and skip it going forward from 2024-04-01. + controller.Playlist.Clips.Select(clip => clip.Name).ShouldBe(["2024-04-01", "2025-04-11", "2025-05-10"]); + controller.Playlist.Clips.ShouldBe(vm.Library.FilteredClips.Reverse()); + } + + [Fact] + public async Task LoadClips_ClipsShareATimestamp_PlaylistIsTheListReversed() + { + var tied = new DateTime(2023, 9, 7, 11, 58, 26); + var (vm, controller) = CreateViewModelWithRoots(new() + { + [@"D:\TeslaCam"] = + [ + ClipAt(@"D:\TeslaCam\older", "Older", tied.AddMinutes(-1)), + ClipAt(@"D:\TeslaCam\a", "Copy A", tied), + ClipAt(@"D:\TeslaCam\b", "Copy B", tied), + ClipAt(@"D:\TeslaCam\newer", "Newer", tied.AddMinutes(1)), + ], + }); + + await vm.Library.LoadClipsAsync([@"D:\TeslaCam"]); + + // Tied clips sorted the same way in both lists made Previous skip a row down the list, then step back up to it. + vm.Library.FilteredClips.Select(clip => clip.Name).ShouldBe(["Newer", "Copy A", "Copy B", "Older"]); + controller.Playlist.Clips.Select(clip => clip.Name).ShouldBe(["Older", "Copy B", "Copy A", "Newer"]); + } + + [Theory] + [InlineData(new[] { @"D:\TeslaCam", @"D:\TeslaCam\SavedClips" }, new[] { @"D:\TeslaCam" })] + [InlineData(new[] { @"D:\TeslaCam\SavedClips", @"D:\TeslaCam" }, new[] { @"D:\TeslaCam" })] + [InlineData(new[] { @"D:\TeslaCam", @"d:\teslacam\" }, new[] { @"D:\TeslaCam" })] + [InlineData(new[] { @"D:\", @"D:\TeslaCam" }, new[] { @"D:\" })] + [InlineData(new[] { @"D:\TeslaCam", @"D:\TeslaCam2" }, new[] { @"D:\TeslaCam", @"D:\TeslaCam2" })] + public async Task LoadClips_OverlappingFolders_ScansEachFolderOnce(string[] pickedRoots, string[] expectedScans) + { + var scanned = new List(); + var vm = new MainWindowViewModel(() => null!, clipLoader: root => + { + scanned.Add(root); + return [ClipAt(Path.Combine(root, "clip"), root, new DateTime(2025, 1, 1))]; + }); + + await vm.Library.LoadClipsAsync(pickedRoots); + + // A folder inside another picked folder is already covered by the outer scan, so scanning it too listed every clip in it twice. + scanned.ShouldBe(expectedScans); + vm.Library.ClipCount.ShouldBe(expectedScans.Length); + } + // --- Delete to Recycle Bin: the injectable confirm/recycle delegates keep this off the shell --- private static List ClipsWithDistinctPaths(int count) => diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index 8ac06bd..082c750 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -16,6 +16,7 @@ namespace SentryDeck; /// public sealed partial class ClipLibraryViewModel : ObservableObject { + // Kept in the order the list shows (newest first), so the list and the player's playlist come from one ordering and can't disagree about what Next and Previous mean. private readonly List _allClips = []; private readonly Func> _clipLoader; private readonly PlaybackViewModel _playback; @@ -50,8 +51,6 @@ public ClipLibraryViewModel( public IReadOnlyList FilteredClips => _allClips .Where(MatchesFilter) - .OrderByDescending(c => c.Timestamp) - .ThenBy(c => c.Name) .ToList(); /// Number of clips currently shown (drives the sidebar count). @@ -151,7 +150,9 @@ public async Task LoadClipsAsync(IEnumerable roots, TimeSpan minimumLoad } } - _playback.SetPlaylist(_allClips); + // Next and Previous step through the playlist from oldest to newest, so it has to be the list's exact reverse. + // Any other ordering sends them out of list order when several folders are loaded or clips share a timestamp. + _playback.SetPlaylist(Enumerable.Reverse(_allClips)); // Hold the loading state briefly so a fast rescan still reads as a deliberate refresh (clear -> loading -> refill) instead of an imperceptible flicker. var remaining = minimumLoadingDuration - stopwatch.Elapsed; @@ -201,7 +202,7 @@ private void OnFilterDebounceTick(object sender, EventArgs e) private ScanResult ScanRoots(IEnumerable roots) { - var rootList = roots?.Where(root => !string.IsNullOrWhiteSpace(root)).ToList() ?? []; + var rootList = WithoutOverlappingRoots(roots?.Where(root => !string.IsNullOrWhiteSpace(root)) ?? []); if (rootList.Count == 0) { Log.Information("No dashcam roots found"); @@ -246,7 +247,64 @@ private ScanResult ScanRoots(IEnumerable roots) rootList.Count, errors.Count, totalStopwatch.ElapsedMilliseconds); - return new ScanResult(clips, errors, HadRoots: true); + + // Each root comes back sorted on its own, so the merged list is sorted again across roots. + // The full path settles clips that share a timestamp and name (copies of one clip in two folders), so they keep the same order on every scan. + var newestFirst = clips + .OrderByDescending(clip => clip.Timestamp) + .ThenBy(clip => clip.Name) + .ThenBy(clip => clip.FullPath, StringComparer.OrdinalIgnoreCase) + .ToList(); + return new ScanResult(newestFirst, errors, HadRoots: true); + } + + /// + /// Drops roots that repeat another root or sit inside one, keeping the first spelling the user picked. + /// The scan already recurses, so a folder inside another picked folder would list every clip in it twice. + /// + private static List WithoutOverlappingRoots(IEnumerable roots) + { + var normalized = roots + .Select(root => (Root: root, FullPath: NormalizeRoot(root))) + .DistinctBy(root => root.FullPath, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var kept = new List(); + foreach (var root in normalized) + { + var outerRoot = normalized.FirstOrDefault(other => IsInside(root.FullPath, other.FullPath)).Root; + if (outerRoot is null) + { + kept.Add(root.Root); + } + else + { + Log.Information("Skipping dashcam root inside another picked root. Root={Root}; OuterRoot={OuterRoot}", root.Root, outerRoot); + } + } + + return kept; + } + + // Falls back to the root as given when it isn't a valid path, so the scan still reports that root's own error. + private static string NormalizeRoot(string root) + { + try + { + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException) + { + return root; + } + } + + private static bool IsInside(string path, string folder) + { + // A drive root keeps its trailing separator; anything else needs one so D:\TeslaCam2 doesn't count as inside D:\TeslaCam. + // The length check keeps a drive root from counting as inside itself. + var prefix = Path.EndsInDirectorySeparator(folder) ? folder : folder + Path.DirectorySeparatorChar; + return path.Length > prefix.Length && path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); } private sealed record ScanResult(IReadOnlyList Clips, IReadOnlyList Errors, bool HadRoots); From 5a91d6fb8f41e7c70e21da842b090ce938e4eb1d Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:27:00 -0500 Subject: [PATCH 24/73] Keep playing the open clip in view when a search hides it Searching for anything the playing clip didn't match blanked the video to "Select a clip to begin" while the clip kept playing out of sight, with Pause, Stop, the seek bar and Trim still acting on it, and Play disabled if it was paused. Clearing the search didn't bring it back: its row had the now-playing badge but no highlight, and the pane stayed empty. The list can't keep a hidden row selected, so it wrote null back through its two-way selection binding, and the app took that as the user closing the clip. The list now binds to a separate list selection that ignores the null for a row the search hides, so the clip stays selected, playing and visible. When a later search shows the clip again, its row is highlighted again without reopening it or closing an open trim panel. Found in v1.1.0 local testing: F77, F06, F54, F106, F113. --- .../MainWindowViewModelTests.Clips.cs | 61 +++++++++++++++++++ .../MainWindowViewModelTests.Keyboard.cs | 2 +- SentryDeck/MainWindow.xaml | 2 +- SentryDeck/ViewModels/ClipLibraryViewModel.cs | 35 +++++++++++ SentryDeck/ViewModels/PlaybackViewModel.cs | 2 +- 5 files changed, 99 insertions(+), 3 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 2817d4c..ea9449a 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -145,6 +145,67 @@ public async Task TypingInSearch_DoesNotRebindTheListPerKeystroke() changed.ShouldContain(nameof(ClipLibraryViewModel.HasFilterText)); } + // --- A search that hides the open clip: the list can't keep a hidden row selected, so it writes null back through its selection binding. --- + + [Fact] + public async Task Search_HidesTheOpenClip_KeepsItOpenAndInView() + { + var clips = TestClips.Create(3); + var vm = await LoadedViewModelAsync(clips); + var open = clips[1]; + vm.Library.ListSelection = open; + + vm.Library.FilterText = "zzzz"; + vm.Library.ApplyFilter(); + vm.Library.ListSelection = null; + + // Treating that null as a deselect blanked the video to "Select a clip to begin" while the clip kept playing out of sight. + vm.Library.SelectedClip.ShouldBe(open); + vm.Playback.NowPlayingClip.ShouldBe(open); + vm.ShowVideoHosts.ShouldBeTrue(); + vm.HasNoClipSelected.ShouldBeFalse(); + vm.Library.ListSelection.ShouldBeNull(); + } + + [Fact] + public async Task Search_ClearedAfterHidingTheOpenClip_HighlightsItsRowWithoutReopeningIt() + { + var clips = TestClips.Create(3); + var vm = await LoadedViewModelAsync(clips); + var open = clips[1]; + vm.Library.ListSelection = open; + vm.Library.FilterText = "zzzz"; + vm.Library.ApplyFilter(); + vm.Library.ListSelection = null; + + var changed = new List(); + vm.Library.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + vm.Library.FilterText = string.Empty; + vm.Library.ApplyFilter(); + + // The list only re-reads its selection when told, so without the notification the row stayed unhighlighted. + vm.Library.ListSelection.ShouldBe(open); + changed.ShouldContain(nameof(ClipLibraryViewModel.ListSelection)); + + // Announcing a new selection instead would reload the clip that is already playing and close an open trim panel. + changed.ShouldNotContain(nameof(ClipLibraryViewModel.SelectedClip)); + } + + [Fact] + public async Task ListSelection_ClearedWhileItsRowIsShown_DeselectsTheClip() + { + var clips = TestClips.Create(3); + var vm = await LoadedViewModelAsync(clips); + vm.Library.ListSelection = clips[1]; + + vm.Library.FilterText = "Clip 1"; + vm.Library.ApplyFilter(); + vm.Library.ListSelection = null; + + // Only a row the search hides is protected; deselecting a row the user can see still reaches the selection. + vm.Library.SelectedClip.ShouldBeNull(); + } + [Fact] public void ShowOnMap_DisabledWithoutCoordinates() { diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs index 16f1133..f980d5c 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Keyboard.cs @@ -131,7 +131,7 @@ public async Task DeselectingWhileSelectionLoadIsYielding_ClearsTheLoadingOverla 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). + // Clear the selection before the yield resumes (a Ctrl+click deselect). // The superseded load must not leave IsLoading stuck true forever. vm.Library.SelectedClip = null; yieldGate.SetResult(); diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index 0648461..846b625 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -652,7 +652,7 @@ PreviewMouseLeftButtonUp="ClipListBox_PreviewMouseLeftButtonUp" PreviewMouseRightButtonDown="ClipListBox_PreviewMouseRightButtonDown" ScrollViewer.HorizontalScrollBarVisibility="Disabled" - SelectedItem="{Binding Library.SelectedClip, Mode=TwoWay}" + SelectedItem="{Binding Library.ListSelection, Mode=TwoWay}" VirtualizingPanel.IsVirtualizingWhenGrouping="True" VirtualizingPanel.VirtualizationMode="Recycling"> diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index 082c750..9c2ced1 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -65,8 +65,29 @@ public ClipLibraryViewModel( private string _filterText = string.Empty; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ListSelection))] private CamClip _selectedClip; + /// + /// The row the clip list highlights, which the list binds two-way: the selected clip while the search shows it, otherwise none. + /// Kept apart from so a search that hides the open clip only hides its row, and the clip keeps playing in view. + /// + public CamClip ListSelection + { + get => IsShownInList(SelectedClip) ? SelectedClip : null; + set + { + // The list drops its selection, and writes null back here, when a search hides the selected row. + // That isn't the user closing the clip, so the clip stays selected and its row is highlighted again once the search shows it. + if (value is null && !IsShownInList(SelectedClip)) + { + return; + } + + SelectedClip = value; + } + } + // True while the clip list is being (re)scanned from disk; drives the sidebar loading indicator. [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RefreshClipsCommand))] @@ -186,6 +207,8 @@ private bool MatchesFilter(CamClip clip) || ClipDisplay.ReasonLabel(clip).Contains(term, StringComparison.CurrentCultureIgnoreCase); } + private bool IsShownInList(CamClip clip) => clip is not null && MatchesFilter(clip); + // Restart the debounce on each keystroke; the list is rebound once typing settles. partial void OnFilterTextChanged(string value) { @@ -196,8 +219,20 @@ partial void OnFilterTextChanged(string value) private void OnFilterDebounceTick(object sender, EventArgs e) { _filterDebounceTimer.Stop(); + ApplyFilter(); + } + + /// + /// Rebinds the list to the current search. + /// The debounce timer calls it once typing settles; tests call it directly because the timer never ticks without a message loop. + /// + internal void ApplyFilter() + { OnPropertyChanged(nameof(FilteredClips)); OnPropertyChanged(nameof(ClipCount)); + + // A search that hid the selected clip left its row unhighlighted, and nothing else tells the list to highlight it again once a later search shows it. + OnPropertyChanged(nameof(ListSelection)); } private ScanResult ScanRoots(IEnumerable roots) diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index ee88f07..abe6e91 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -416,7 +416,7 @@ private async Task PlaySelectedClipAsync(CamClip clip, CancellationToken cancell // A newer selection superseded this one while we yielded, so drop it and let the latest win and the selection doesn't rubber-band backwards as earlier, slower loads complete. if (cancellationToken.IsCancellationRequested || !ReferenceEquals(clip, SelectedClip)) { - // A newer selection's own load owns IsLoading now, but if the selection was cleared outright (deselect, or a filter dropping the clip), no load is in flight and nothing else ever resets the flag, leaving a permanent "Loading…" overlay over the video pane. + // A newer selection's own load owns IsLoading now, but if the selection was cleared outright (a deselect), no load is in flight and nothing else ever resets the flag, leaving a permanent "Loading…" overlay over the video pane. if (SelectedClip is null) { IsLoading = false; From dd771bd381c5a1cc209a6ee8f85c6da92ec22274 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:37:14 -0500 Subject: [PATCH 25/73] Keep Next and Previous inside the search results With a search active, Next and Previous opened clips the search hides, and stayed enabled at the first and last result. The list can't highlight a row it doesn't show, so it kept highlighting the clip they left (now without its now-playing badge), and clicking that row did nothing. The player's playlist is the whole library, and Next and Previous stepped through it one clip at a time whatever the search showed. The playlist now takes a navigation filter, and the clip list hands it the search, so Next and Previous skip to the nearest clip the list shows and are disabled when there is none. The list highlight follows every clip they open. A playing clip the search hides stays open, and Next and Previous continue from its place in the library. Found in v1.1.0 local testing: F39, F78, F112. --- SentryDeck.Data/Playback/ClipPlaylist.cs | 35 +++++- SentryDeck.Tests/ClipPlaylistTests.cs | 88 +++++++++++++++ .../MainWindowViewModelTests.Clips.cs | 101 ++++++++++++++++++ SentryDeck/ViewModels/ClipLibraryViewModel.cs | 6 ++ SentryDeck/ViewModels/PlaybackViewModel.cs | 10 ++ 5 files changed, 238 insertions(+), 2 deletions(-) diff --git a/SentryDeck.Data/Playback/ClipPlaylist.cs b/SentryDeck.Data/Playback/ClipPlaylist.cs index c18b748..805bc54 100644 --- a/SentryDeck.Data/Playback/ClipPlaylist.cs +++ b/SentryDeck.Data/Playback/ClipPlaylist.cs @@ -7,6 +7,11 @@ namespace SentryDeck; /// public sealed class ClipPlaylist { + private static readonly Func AcceptEveryClip = _ => true; + + // Next and Previous only land on clips this accepts, so with a search active they walk the clips the list shows instead of the whole library. + private Func _canNavigateTo = AcceptEveryClip; + private readonly List _clips = []; private int _currentIndex = -1; @@ -25,9 +30,10 @@ public sealed class ClipPlaylist public bool HasNext => IsValidIndex(NextIndex); // The clip that followed a removed current clip slid into its slot, so that slot is next and the one before it is previous. - private int NextIndex => _removedCurrentIndex >= 0 ? _removedCurrentIndex : _currentIndex + 1; + // From there, each skips the clips the navigation filter rejects and lands on the nearest one it accepts, or -1 when there is none. + private int NextIndex => FindNavigableIndex(_removedCurrentIndex >= 0 ? _removedCurrentIndex : _currentIndex + 1, step: 1); - private int PreviousIndex => _removedCurrentIndex >= 0 ? _removedCurrentIndex - 1 : _currentIndex - 1; + private int PreviousIndex => FindNavigableIndex(_removedCurrentIndex >= 0 ? _removedCurrentIndex - 1 : _currentIndex - 1, step: -1); public event EventHandler CurrentClipChanged; public event EventHandler PlaylistChanged; @@ -107,11 +113,36 @@ public bool RemoveClip(CamClip clip) return true; } + /// + /// Limits Next and Previous to the clips accepts; null accepts every clip again. + /// The filter outlives , so a rescan keeps honoring a search that is still active. + /// The current clip stays current even when the filter rejects it, so a search that hides the open clip doesn't close it, and Next and Previous continue from its place. + /// Raises no event: the clips and the current clip are unchanged, and would have the player announce its clip to the list again, reselecting a clip the user had deselected. + /// Callers re-read and themselves. + /// + public void SetNavigationFilter(Func canNavigateTo) + { + _canNavigateTo = canNavigateTo ?? AcceptEveryClip; + } + public void Clear() { SetClips([]); } + private int FindNavigableIndex(int start, int step) + { + for (var index = start; IsValidIndex(index); index += step) + { + if (_canNavigateTo(_clips[index])) + { + return index; + } + } + + return -1; + } + private bool IsValidIndex(int index) { return index >= 0 && index < _clips.Count; diff --git a/SentryDeck.Tests/ClipPlaylistTests.cs b/SentryDeck.Tests/ClipPlaylistTests.cs index 26a765e..2f2769d 100644 --- a/SentryDeck.Tests/ClipPlaylistTests.cs +++ b/SentryDeck.Tests/ClipPlaylistTests.cs @@ -54,6 +54,94 @@ public void MoveNextAndPrevious_RespectPlaylistBounds() playlist.CurrentClip.ShouldBe(clips[0]); } + [Fact] + public void MoveNextAndPrevious_WithANavigationFilter_SkipTheClipsItRejects() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(5); + playlist.SetClips(clips); + playlist.SetNavigationFilter(clip => clip != clips[1] && clip != clips[3]); + playlist.MoveTo(2); + + // With a search active, stepping by one landed on clips the search hides, which the list can't show as selected. + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[4]); + playlist.MovePrevious().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[2]); + playlist.MovePrevious().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[0]); + } + + [Fact] + public void HasNextAndPrevious_WithANavigationFilter_IgnoreTheClipsItRejects() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(4); + playlist.SetClips(clips); + playlist.SetNavigationFilter(clip => clip == clips[1] || clip == clips[2]); + playlist.MoveTo(2); + + // The buttons stayed enabled at the edge of the search results and then opened a hidden clip. + playlist.HasNext.ShouldBeFalse(); + playlist.MoveNext().ShouldBeFalse(); + playlist.CurrentClip.ShouldBe(clips[2]); + + playlist.MoveTo(1); + playlist.HasPrevious.ShouldBeFalse(); + playlist.MovePrevious().ShouldBeFalse(); + playlist.CurrentClip.ShouldBe(clips[1]); + } + + [Fact] + public void SetNavigationFilter_RejectingTheCurrentClip_KeepsItCurrentAndStepsToItsNearestAcceptedNeighbours() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(5); + playlist.SetClips(clips); + playlist.MoveTo(2); + var currentChanged = 0; + playlist.CurrentClipChanged += (_, _) => currentChanged++; + + playlist.SetNavigationFilter(clip => clip == clips[0] || clip == clips[4]); + + // A search that hides the open clip mustn't close it, and Next and Previous carry on from its place. + currentChanged.ShouldBe(0); + playlist.CurrentClip.ShouldBe(clips[2]); + playlist.HasNext.ShouldBeTrue(); + playlist.HasPrevious.ShouldBeTrue(); + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[4]); + } + + [Fact] + public void SetNavigationFilter_Null_LetsNextAndPreviousReachEveryClipAgain() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetClips(clips); + playlist.SetNavigationFilter(clip => clip != clips[1]); + playlist.MoveTo(0); + + playlist.SetNavigationFilter(null); + + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[1]); + } + + [Fact] + public void SetClips_WithANavigationFilterSet_KeepsTheFilter() + { + var playlist = new ClipPlaylist(); + var clips = TestClips.Create(3); + playlist.SetNavigationFilter(clip => clip != clips[0]); + + playlist.SetClips(clips); + + // A rescan replaces the clips while the search stays in the box, so Next has to keep skipping what it hides. + playlist.MoveNext().ShouldBeTrue(); + playlist.CurrentClip.ShouldBe(clips[1]); + } + [Fact] public void MoveTo_WithClipReference_SelectsClipAndRaisesEvent() { diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index ea9449a..75e71fc 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -429,6 +429,107 @@ public async Task LoadClips_OverlappingFolders_ScansEachFolderOnce(string[] pick vm.Library.ClipCount.ShouldBe(expectedScans.Length); } + // --- Next/Previous with a search: they walk the clips the list shows, so the list can highlight every clip they open. --- + + // A clip recorded in a city on 2025-08-08 at the given hour, so a search for the city shows it and hides the others. + private static CamClip ClipInCity(string city, int hour) + { + var timestamp = new DateTime(2025, 8, 8, hour, 0, 0); + var name = $"{timestamp:yyyy-MM-dd_HH-mm-ss}"; + return new CamClip($@"D:\TeslaCam\SavedClips\{name}", name, timestamp, [], new CamEvent { City = city, Timestamp = timestamp }); + } + + // Oldest to newest, with two hidden clips between the Austin ones so that skipping one isn't enough. + private readonly CamClip _olderAustin = ClipInCity("Austin", 9); + private readonly CamClip _springfield = ClipInCity("Springfield", 10); + private readonly CamClip _kyle = ClipInCity("Kyle", 11); + private readonly CamClip _newerAustin = ClipInCity("Austin", 12); + private readonly CamClip _hutto = ClipInCity("Hutto", 13); + + private async Task<(MainWindowViewModel Vm, VideoPlayerController Controller)> CitiesLoadedAsync() + { + var (vm, controller) = CreateViewModelWithRoots(new() + { + [@"D:\TeslaCam"] = [_olderAustin, _springfield, _kyle, _newerAustin, _hutto], + }); + await vm.Library.LoadClipsAsync([@"D:\TeslaCam"]); + return (vm, controller); + } + + private static void Search(MainWindowViewModel vm, string text) + { + vm.Library.FilterText = text; + vm.Library.ApplyFilter(); + } + + [Fact] + public async Task PreviousCommand_WithASearch_OpensTheNextOlderClipTheListShows() + { + var (vm, controller) = await CitiesLoadedAsync(); + Search(vm, "Austin"); + vm.Library.ListSelection = _newerAustin; + + RunPinnedToTestThread(() => vm.Playback.PreviousCommand.ExecuteAsync(null)); + + // Stepping through the whole library opened the Kyle clip the search hides, while the list kept highlighting the Austin clip it left. + controller.CurrentClip.ShouldBe(_olderAustin); + vm.Library.SelectedClip.ShouldBe(_olderAustin); + vm.Library.ListSelection.ShouldBe(_olderAustin); + vm.Playback.NowPlayingClip.ShouldBe(_olderAustin); + } + + [Fact] + public async Task NextAndPrevious_WithASearch_AreDisabledAtTheEdgesOfTheResults() + { + var (vm, _) = await CitiesLoadedAsync(); + vm.Library.ListSelection = _newerAustin; + vm.Playback.CanGoNext.ShouldBeTrue(); + var changed = new List(); + vm.Playback.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + Search(vm, "Austin"); + + // The newer Hutto clip is hidden, so Next at the top of the results would open a clip the list can't show. + vm.Playback.CanGoNext.ShouldBeFalse(); + vm.Playback.CanGoPrevious.ShouldBeTrue(); + + // The buttons only re-read these when told, so a search that changes them has to say so. + changed.ShouldContain(nameof(PlaybackViewModel.CanGoNext)); + changed.ShouldContain(nameof(PlaybackViewModel.CanGoPrevious)); + + vm.Library.ListSelection = _olderAustin; + vm.Playback.CanGoPrevious.ShouldBeFalse(); + } + + [Fact] + public async Task NextCommand_FromAClipTheSearchHides_OpensTheNearestNewerClipTheListShows() + { + var (vm, controller) = await CitiesLoadedAsync(); + vm.Library.ListSelection = _springfield; + Search(vm, "Austin"); + vm.Library.ListSelection = null; // the list drops the row the search hid + + RunPinnedToTestThread(() => vm.Playback.NextCommand.ExecuteAsync(null)); + + // The open clip stays where it is in the library, so Next continues from there to the next clip the user can see. + controller.CurrentClip.ShouldBe(_newerAustin); + vm.Library.ListSelection.ShouldBe(_newerAustin); + } + + [Fact] + public async Task NextCommand_AfterTheSearchIsCleared_ReachesEveryClipAgain() + { + var (vm, controller) = await CitiesLoadedAsync(); + Search(vm, "Austin"); + vm.Library.ListSelection = _olderAustin; + Search(vm, string.Empty); + + RunPinnedToTestThread(() => vm.Playback.NextCommand.ExecuteAsync(null)); + + controller.CurrentClip.ShouldBe(_springfield); + vm.Library.ListSelection.ShouldBe(_springfield); + } + // --- Delete to Recycle Bin: the injectable confirm/recycle delegates keep this off the shell --- private static List ClipsWithDistinctPaths(int count) => diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index 9c2ced1..9f5dc1a 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -175,6 +175,9 @@ public async Task LoadClipsAsync(IEnumerable roots, TimeSpan minimumLoad // Any other ordering sends them out of list order when several folders are loaded or clips share a timestamp. _playback.SetPlaylist(Enumerable.Reverse(_allClips)); + // A search typed before the player existed (while FFmpeg was still missing) never reached it, so every new playlist gets the search too. + _playback.SetNavigationFilter(MatchesFilter); + // Hold the loading state briefly so a fast rescan still reads as a deliberate refresh (clear -> loading -> refill) instead of an imperceptible flicker. var remaining = minimumLoadingDuration - stopwatch.Elapsed; if (remaining > TimeSpan.Zero) @@ -233,6 +236,9 @@ internal void ApplyFilter() // A search that hid the selected clip left its row unhighlighted, and nothing else tells the list to highlight it again once a later search shows it. OnPropertyChanged(nameof(ListSelection)); + + // Next and Previous walk only the clips the list shows: a clip they opened behind the search couldn't be highlighted, so the list would keep pointing at the clip they left. + _playback.SetNavigationFilter(MatchesFilter); } private ScanResult ScanRoots(IEnumerable roots) diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index abe6e91..b81e13d 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -240,6 +240,16 @@ public void RemoveFromPlaylist(CamClip clip) NotifyNavigationChanged(); } + /// + /// Limits Next and Previous to the clips accepts, such as the ones a search shows, so they never open a clip the list can't highlight. + /// The open clip stays open even when it is rejected. + /// + public void SetNavigationFilter(Func canNavigateTo) + { + _playerController?.Playlist.SetNavigationFilter(canNavigateTo); + NotifyNavigationChanged(); + } + /// Closes whatever the player has open, releasing its file handles. public Task StopPlayerAsync() => _playerController?.StopAsync() ?? Task.CompletedTask; From cc488190570c1907d3b80e33001435996d1dd9ae Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:47:27 -0500 Subject: [PATCH 26/73] Say when a folder has no clips or can't be found Rescanning a picked folder that had been renamed, moved, unplugged, or locked down emptied the list without a word, as if the footage had been deleted. Picking a folder with no clips (the wrong folder, or a freshly formatted Tesla drive) or deleting the last clip did the same. Each time, the overlay said "Select a clip to begin. Your dashcam timeline is on the left." beside an empty list. The scan skips any folder it can't read so one damaged clip can't hide the rest, so a missing or unreadable root came back as an empty library and never reached the scan's own error messages. A root that yields no clips is now opened once more without that leniency, so a missing folder shows "Folder Not Found" and a denied one shows "Access Denied". A library that is empty after a clean scan, or after deleting its last clip, now says "No clips found in this folder", names the folders it looked in, and offers Select folder. That notice, the first-run "No dashcam footage yet" prompt, and a scan error that leaves no clips now stay up until a scan finds clips, because dismissing one only uncovered "Select a clip to begin" beside the empty list again. They cover only the video area, so the sidebar can still pick a folder or rescan. Found in v1.1.0 local testing: F29, F28, F102, F114. --- .../MainWindowViewModelTests.Clips.cs | 100 +++++++++++++++++- SentryDeck/ViewModels/ClipLibraryViewModel.cs | 65 +++++++++++- 2 files changed, 157 insertions(+), 8 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 75e71fc..750b261 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -280,18 +280,20 @@ public void CopyClipPath_AnyClip_CopiesTheFolderPath() // The overlay is the whole UI in these states, so its wording and its dismissibility are the behavior. --- [Fact] - public async Task LoadClips_WithNoRoots_ShowsDismissibleEmptyState() + public async Task LoadClips_WithNoRoots_ShowsAnEmptyStateThatStaysUntilFootageIsFound() { var vm = CreateViewModel(); 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. + // First run with no USB drive attached: a friendly prompt, not a scary error. + // It only covers the video area, so the sidebar stays usable; dismissing it only uncovered "Select a clip to begin" beside an empty list. vm.Error.Title.ShouldBe("No dashcam footage yet"); vm.Error.IsEmptyState.ShouldBeTrue(); - vm.Error.CanDismiss.ShouldBeTrue(); + vm.Error.CanDismiss.ShouldBeFalse(); vm.Error.IsVisible.ShouldBeTrue(); vm.ShowStatusOverlay.ShouldBeTrue(); + vm.HasNoClipSelected.ShouldBeFalse(); vm.Library.ClipCount.ShouldBe(0); } @@ -344,6 +346,98 @@ public async Task LoadClips_OneRootFails_KeepsClipsFromTheHealthyRoot() vm.Library.ClipCount.ShouldBe(2); vm.Error.IsVisible.ShouldBeTrue(); vm.Error.Title.ShouldBe("Error Loading Clips"); + vm.Error.CanDismiss.ShouldBeTrue(); + } + + // --- An empty library: a folder with no clips, and one that is gone or can't be opened, each say so instead of "Select a clip to begin" beside an empty list. + // These use the real loader, because it is the one that skips unreadable folders. --- + + [Fact] + public async Task LoadClips_FolderIsGone_ShowsFolderNotFound() + { + var vm = CreateViewModel(); + var gone = Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}"); + + await vm.Library.LoadClipsAsync([gone]); + + // Rescanning a folder that was renamed or unplugged emptied the list without a word, as if the footage had been deleted. + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Folder Not Found"); + vm.Error.Details.ShouldContain(gone); + vm.Error.IsEmptyState.ShouldBeFalse(); + vm.HasNoClipSelected.ShouldBeFalse(); + + // The library is empty, so dismissing the error would only uncover "Select a clip to begin" beside an empty list. + vm.Error.CanDismiss.ShouldBeFalse(); + } + + [Fact] + public async Task LoadClips_FolderCantBeListed_ShowsAccessDenied() + { + using var folder = new TempDirectory(); + var vm = CreateViewModel(); + + // The probe refuses the folder instead of a deny rule on it, because an elevated account like the CI runner's can still list a folder whose rules deny it. + vm.Library.ThrowIfCantList = path => + { + if (path == folder.Path) + { + throw new UnauthorizedAccessException($"Access to the path '{path}' is denied."); + } + }; + + await vm.Library.LoadClipsAsync([folder.Path]); + + // The scan skips folders it may not list, so a root the user can't open looked like an empty library too. + vm.Error.Title.ShouldBe("Access Denied"); + vm.Error.Details.ShouldContain(folder.Path); + } + + [Fact] + public async Task LoadClips_FolderHasNoClips_SaysSoInsteadOfAskingForAClip() + { + using var folder = new TempDirectory(); + var vm = CreateViewModel(); + + await vm.Library.LoadClipsAsync([folder.Path]); + + // "Select a clip to begin" beside an empty list read as if there were clips to pick, and nothing hinted that the wrong folder was chosen. + vm.Error.Title.ShouldBe("No clips found in this folder"); + vm.Error.Details.ShouldContain(folder.Path); + vm.Error.IsEmptyState.ShouldBeTrue(); + vm.Error.CanDismiss.ShouldBeFalse(); + vm.HasNoClipSelected.ShouldBeFalse(); + vm.ShowStatusOverlay.ShouldBeTrue(); + } + + [Fact] + public async Task LoadClips_SeveralFoldersWithNoClips_NamesEveryFolder() + { + using var first = new TempDirectory(); + using var second = new TempDirectory(); + var vm = CreateViewModel(); + + await vm.Library.LoadClipsAsync([first.Path, second.Path]); + + vm.Error.Title.ShouldBe("No clips found in these folders"); + vm.Error.Details.ShouldContain(first.Path); + vm.Error.Details.ShouldContain(second.Path); + } + + [Fact] + public async Task DeleteClip_TheLastClip_SaysTheFolderHasNoClips() + { + var vm = await LoadedViewModelAsync(ClipsWithDistinctPaths(1)); + vm.Library.ConfirmDeleteClip = (_, _) => true; + vm.Library.RecycleClipFolder = _ => { }; + + await vm.Library.DeleteClipCommand.ExecuteAsync(vm.Library.FilteredClips.Single()); + + // Deleting the last clip left the same "Select a clip to begin" prompt over a list with nothing in it. + vm.Error.Title.ShouldBe("No clips found in this folder"); + vm.Error.IsEmptyState.ShouldBeTrue(); + vm.Error.CanDismiss.ShouldBeFalse(); + vm.HasNoClipSelected.ShouldBeFalse(); } // --- Next/Previous order: the player's playlist must be the list's exact reverse, since Next and Previous step through it by index. --- diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index 9f5dc1a..c19563c 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -27,6 +27,9 @@ public sealed partial class ClipLibraryViewModel : ObservableObject // Refresh re-evaluates it to rescan for newly added clips (and, for auto-discovery, newly connected drives). private Func> _rootSource = CamStorage.FindCommonRoots; + // The roots the last scan read, so an empty library can name the folders it looked in. + private IReadOnlyList _scannedRoots = []; + /// Maps a dashcam root to its clips. /// The player, which is stopped before a rescan or a delete and follows the clip list's playlist. /// Where scan and delete failures are reported. @@ -153,13 +156,16 @@ public async Task LoadClipsAsync(IEnumerable roots, TimeSpan minimumLoad { // Scan the disk off the UI thread; the continuation resumes on it via the WPF SynchronizationContext, so all view-model state below is mutated on the UI thread. var result = await Task.Run(() => ScanRoots(roots)); + _scannedRoots = result.Roots; + // A notice over an empty library stays until a scan finds clips: dismissing it would only uncover "Select a clip to begin" beside an empty list. + // It covers only the video area, so the sidebar can still pick a folder or rescan. if (!result.HadRoots) { _error.Show( "No dashcam footage yet", "Point Sentry Deck at your TeslaCam folder to get started. Recorded USB drives are found automatically.", - canDismiss: true, + canDismiss: false, isEmptyState: true); } else @@ -167,7 +173,12 @@ public async Task LoadClipsAsync(IEnumerable roots, TimeSpan minimumLoad _allClips.AddRange(result.Clips); foreach (var error in result.Errors) { - _error.Show(error.Title, error.Details); + _error.Show(error.Title, error.Details, canDismiss: _allClips.Count > 0); + } + + if (_allClips.Count == 0 && result.Errors.Count == 0) + { + ShowNoClipsFound(); } } @@ -195,6 +206,19 @@ public async Task LoadClipsAsync(IEnumerable roots, TimeSpan minimumLoad public void Shutdown() => _filterDebounceTimer.Stop(); + /// + /// Says the library is empty and names the folders it came from, until a scan finds clips. + /// Otherwise the overlay fell back to "Select a clip to begin" beside an empty list, which reads as if there were clips to pick and never hints that the wrong folder was chosen. + /// + private void ShowNoClipsFound() + { + _error.Show( + _scannedRoots.Count == 1 ? "No clips found in this folder" : "No clips found in these folders", + $"{string.Join("\n", _scannedRoots)}\n\nSelect the TeslaCam folder from your car's USB drive, or a folder you copied your clips to.", + canDismiss: false, + isEmptyState: true); + } + // Matches the clip name, path, event city, and friendly event reason (e.g. "sentry", "honk", "saved"). private bool MatchesFilter(CamClip clip) { @@ -247,7 +271,7 @@ private ScanResult ScanRoots(IEnumerable roots) if (rootList.Count == 0) { Log.Information("No dashcam roots found"); - return new ScanResult([], [], HadRoots: false); + return new ScanResult(rootList, [], []); } Log.Information("Loading dashcam clips. RootCount={RootCount}; Roots={Roots}", rootList.Count, rootList); @@ -263,6 +287,14 @@ private ScanResult ScanRoots(IEnumerable roots) try { var rootClips = _clipLoader(root); + + // The scan skips any folder it can't read, so one damaged clip can't hide the rest, but that also turned a root that is gone or locked into an empty library. + // Opening an empty root once more without that leniency tells a folder that really has no clips apart from one that was moved, unplugged, or can't be opened. + if (rootClips.Count == 0) + { + ThrowIfCantList(root); + } + clips.AddRange(rootClips); Log.Information( "Scanned dashcam root. Root={Root}; ClipCount={ClipCount}; ElapsedMs={ElapsedMs}", @@ -270,6 +302,11 @@ private ScanResult ScanRoots(IEnumerable roots) rootClips.Count, rootStopwatch.ElapsedMilliseconds); } + catch (DirectoryNotFoundException ex) + { + Log.Error(ex, "Dashcam root not found. Root={Root}", root); + errors.Add(new ClipLoadError("Folder Not Found", $"Can't find this folder:\n{root}\n\nIt may have been moved or renamed, or its drive disconnected.")); + } catch (UnauthorizedAccessException ex) { Log.Error(ex, "Access denied while loading dashcam root. Root={Root}", root); @@ -296,9 +333,19 @@ private ScanResult ScanRoots(IEnumerable roots) .ThenBy(clip => clip.Name) .ThenBy(clip => clip.FullPath, StringComparer.OrdinalIgnoreCase) .ToList(); - return new ScanResult(newestFirst, errors, HadRoots: true); + return new ScanResult(rootList, newestFirst, errors); } + /// + /// Throws when a folder is gone or the user may not list it. + /// Directory.Exists is true for a folder the user may not list, so the default opens the folder instead, which throws for a missing folder and for a denied one alike. + /// Overridable for tests, so they don't depend on the rights of the account running them. + /// + internal Action ThrowIfCantList { get; set; } = folder => + { + using var listing = Directory.EnumerateFileSystemEntries(folder).GetEnumerator(); + }; + /// /// Drops roots that repeat another root or sit inside one, keeping the first spelling the user picked. /// The scan already recurses, so a folder inside another picked folder would list every clip in it twice. @@ -348,7 +395,10 @@ private static bool IsInside(string path, string folder) return path.Length > prefix.Length && path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); } - private sealed record ScanResult(IReadOnlyList Clips, IReadOnlyList Errors, bool HadRoots); + private sealed record ScanResult(IReadOnlyList Roots, IReadOnlyList Clips, IReadOnlyList Errors) + { + public bool HadRoots => Roots.Count > 0; + } private sealed record ClipLoadError(string Title, string Details); @@ -599,6 +649,11 @@ Task ReopenAsync(bool play) => reopenAt is { } position OnPropertyChanged(nameof(FilteredClips)); OnPropertyChanged(nameof(ClipCount)); + + if (_allClips.Count == 0) + { + ShowNoClipsFound(); + } } [RelayCommand(CanExecute = nameof(CanShowOnMap))] From 9d14373ba8ef42e51a2e5218cb3c9c510198a9a9 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:03:17 -0500 Subject: [PATCH 27/73] Keep the open clip where it was when rescanning Pressing Rescan to pick up newly copied footage closed the clip being reviewed: the list came back with nothing selected, the player read 0:00 / 0:00, and the clip had to be found in the list again and sought back to. Rescan stopped the player, the scan cleared the selection, and every clip came back as a newly read object that nothing matched to the one that was open. Rescan now notes where the open clip was (position, playing or paused, and camera) and, when a clip from the same folder is still there, selects it again and opens it at that place. It opens straight onto the remembered frame instead of starting at the event and seeking back, and keeps the camera on screen rather than jumping to the event's camera. A clip the user had stopped stays closed, and a scan that reports a folder error still shows that error rather than reopening the clip over it. Found in v1.1.0 local testing: F31. --- .../MainWindowViewModelTests.Clips.cs | 149 ++++++++++++++++++ SentryDeck/Playback/VideoPlayerController.cs | 26 ++- SentryDeck/ViewModels/ClipLibraryViewModel.cs | 32 +++- SentryDeck/ViewModels/PlaybackPlace.cs | 11 ++ SentryDeck/ViewModels/PlaybackViewModel.cs | 46 +++++- 5 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 SentryDeck/ViewModels/PlaybackPlace.cs diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 750b261..a3c382e 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -624,6 +624,155 @@ public async Task NextCommand_AfterTheSearchIsCleared_ReachesEveryClipAgain() vm.Library.ListSelection.ShouldBe(_springfield); } + // --- Rescan: every clip is read from disk again, so the open clip comes back as a new object and is found by its folder. --- + + // A real controller, and a loader that reads the clips afresh on every scan the way a rescan of the disk does. + private (MainWindowViewModel Vm, VideoPlayerController Controller, FakeCameraPlayer Front) CreateRescannableViewModel(Func> scan) + { + var front = new FakeCameraPlayer(); + var controller = BuildFourCameraController(front); + var vm = new MainWindowViewModel( + () => controller, + clipLoader: scan, + backgroundYield: () => Task.CompletedTask, + uiInvoker: action => action()); + vm.Library.RootSource = () => [@"D:\TeslaCam"]; + vm.InitializePlayer(); + return (vm, controller, front); + } + + // The clip as a fresh scan reads it: the same folder and footage, but a different object. + private static CamClip ReadAgain(CamClip clip, CamEvent camEvent = null) => + new(clip.FullPath, clip.Name, clip.Timestamp, clip.Chunks, camEvent ?? clip.Event); + + private static async Task OpenClipAsync(MainWindowViewModel vm, VideoPlayerController controller, CamClip clip) + { + await vm.Library.ReloadAsync(); + vm.Library.ListSelection = vm.Library.FilteredClips.Single(listed => listed.FullPath == clip.FullPath); + await controller.WhenIdleAsync(); + } + + [Fact] + public async Task RefreshClips_WhilePlaying_KeepsPlayingTheClipFromWhereItWas() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + var (vm, controller, _) = CreateRescannableViewModel(_ => [ReadAgain(clipFiles.Clip)]); + await OpenClipAsync(vm, controller, clipFiles.Clip); + await controller.SeekAsync(TimeSpan.FromSeconds(75)); + + await vm.Library.RefreshClipsCommand.ExecuteAsync(null); + await controller.WhenIdleAsync(); + + // Rescanning to pick up newly copied footage closed the clip being reviewed, so it had to be found in the list again and sought back to. + var rescanned = vm.Library.FilteredClips.Single(); + vm.Library.SelectedClip.ShouldBeSameAs(rescanned); + vm.Library.ListSelection.ShouldBeSameAs(rescanned); + vm.Playback.NowPlayingClip.ShouldBeSameAs(rescanned); + controller.CurrentClip.ShouldBeSameAs(rescanned); + controller.Position.ShouldBe(TimeSpan.FromSeconds(75)); + controller.IsPlaying.ShouldBeTrue(); + vm.ShowVideoHosts.ShouldBeTrue(); + } + + [Fact] + public async Task RefreshClips_WhilePaused_ReopensTheClipPausedWhereItWas() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 2); + var (vm, controller, front) = CreateRescannableViewModel(_ => [ReadAgain(clipFiles.Clip)]); + await OpenClipAsync(vm, controller, clipFiles.Clip); + await controller.SeekAsync(TimeSpan.FromSeconds(75)); + await controller.PauseAsync(); + var callsBeforeRescan = front.Calls.Count; + + await vm.Library.RefreshClipsCommand.ExecuteAsync(null); + await controller.WhenIdleAsync(); + + // Opening straight onto the paused frame keeps the clip from playing on, even for a moment, past the frame the user stopped on. + var callsSinceRescan = front.Calls.Skip(callsBeforeRescan).ToList(); + callsSinceRescan.ShouldContain("seek:75"); + callsSinceRescan.ShouldNotContain("play"); + controller.Position.ShouldBe(TimeSpan.FromSeconds(75)); + controller.IsPlaying.ShouldBeFalse(); + controller.IsMediaOpen.ShouldBeTrue(); + vm.Library.SelectedClip.ShouldBeSameAs(vm.Library.FilteredClips.Single()); + } + + [Fact] + public async Task RefreshClips_WhileWatchingAPillarCamera_KeepsThatCamera() + { + // An event that names the rear camera, on a clip that also recorded the B-pillars. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var rearEvent = new CamEvent { Reason = "sentry_aware_object_detection", Timestamp = clipFiles.Clip.Timestamp.AddSeconds(30), Camera = 7 }; + var (vm, controller, _) = CreateRescannableViewModel(_ => [ReadAgain(clipFiles.Clip, rearEvent)]); + await OpenClipAsync(vm, controller, clipFiles.Clip); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.LeftPillar); + + await vm.Library.RefreshClipsCommand.ExecuteAsync(null); + await controller.WhenIdleAsync(); + + // Reopening the clip as if it were newly picked jumped to the camera its event names. + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); + } + + [Fact] + public async Task RefreshClips_AfterStop_LeavesTheClipClosed() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, controller, front) = CreateRescannableViewModel(_ => [ReadAgain(clipFiles.Clip)]); + await OpenClipAsync(vm, controller, clipFiles.Clip); + await vm.Playback.StopCommand.ExecuteAsync(null); + + await vm.Library.RefreshClipsCommand.ExecuteAsync(null); + await controller.WhenIdleAsync(); + + // Stop asked for the clip's files to be let go, and a rescan is no reason to open them again. + front.Count("open").ShouldBe(1); + controller.IsMediaOpen.ShouldBeFalse(); + vm.Library.SelectedClip.ShouldBeNull(); + } + + [Fact] + public async Task RefreshClips_TheOpenClipIsGone_LeavesNothingOpen() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var other = ClipAt(@"D:\TeslaCam\SavedClips\2025-01-01_12-00-00", "Other", new DateTime(2025, 1, 1, 12, 0, 0)); + var scans = 0; + var (vm, controller, _) = CreateRescannableViewModel(_ => ++scans == 1 ? [ReadAgain(clipFiles.Clip)] : [other]); + await OpenClipAsync(vm, controller, clipFiles.Clip); + + await vm.Library.RefreshClipsCommand.ExecuteAsync(null); + await controller.WhenIdleAsync(); + + vm.Library.SelectedClip.ShouldBeNull(); + vm.Playback.NowPlayingClip.ShouldBeNull(); + controller.IsMediaOpen.ShouldBeFalse(); + } + + [Fact] + public async Task RefreshClips_AnotherFolderFails_ShowsTheErrorInsteadOfReopening() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var otherDriveClip = ClipAt(@"E:\TeslaCam\SavedClips\2025-01-01_12-00-00", "Other", new DateTime(2025, 1, 1, 12, 0, 0)); + var unplug = false; + var (vm, controller, _) = CreateRescannableViewModel(root => root switch + { + @"E:\TeslaCam" when unplug => throw new IOException("the drive was removed"), + @"E:\TeslaCam" => [otherDriveClip], + _ => [ReadAgain(clipFiles.Clip)], + }); + vm.Library.RootSource = () => [@"D:\TeslaCam", @"E:\TeslaCam"]; + await OpenClipAsync(vm, controller, clipFiles.Clip); + unplug = true; + + await vm.Library.RefreshClipsCommand.ExecuteAsync(null); + await controller.WhenIdleAsync(); + + // Opening a clip clears the notice over the video, so reopening this one would hide why the other folder's clips vanished. + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Error Loading Clips"); + vm.Library.SelectedClip.ShouldBeNull(); + } + // --- Delete to Recycle Bin: the injectable confirm/recycle delegates keep this off the shell --- private static List ClipsWithDistinctPaths(int count) => diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 39bc0de..dbb0361 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -75,6 +75,7 @@ public sealed partial class VideoPlayerController : ObservableObject, IDisposabl private bool _isScrubbing; private bool _resumeAfterScrub; private bool _isDisposed; + private (CamClip Clip, TimeSpan Position, bool Play)? _requestedStart; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanPlayPause))] @@ -359,6 +360,26 @@ public Task GoToClipAsync(int index) return Task.CompletedTask; } + /// + /// Like , but the clip opens at , playing or paused, instead of just before its event. + /// A clip read from disk again keeps the viewer's place this way, without first playing on from its event. + /// + public Task GoToClipAsync(CamClip clip, TimeSpan startPosition, bool play) + { + // Moving to the clip starts its open before returning, so only that open sees the request. + _requestedStart = (clip, startPosition, play); + try + { + Playlist.MoveTo(clip); + } + finally + { + _requestedStart = null; + } + + return Task.CompletedTask; + } + /// /// Opens again at , after closed it to release its files for something that then didn't happen. /// A plain open starts at the event instead, which would lose the user's place. @@ -966,7 +987,10 @@ private void OnCurrentClipChanged(object sender, CamClip clip) Playlist.CurrentIndex, Playlist.Clips.Count); - _ = OpenClipAsync(clip); + // A clip reached through GoToClipAsync with a start position opens there instead of just before its event. + _ = _requestedStart is { } start && start.Clip == clip + ? OpenClipAsync(clip, start.Position, start.Play) + : OpenClipAsync(clip); } private void OnPlaylistChanged(object sender, EventArgs e) diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index c19563c..b30b8b8 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -23,9 +23,12 @@ public sealed partial class ClipLibraryViewModel : ObservableObject private readonly ErrorOverlayViewModel _error; private readonly DispatcherTimer _filterDebounceTimer; - // The source of dashcam roots: auto-discovery by default, or the user's last picked folders. - // Refresh re-evaluates it to rescan for newly added clips (and, for auto-discovery, newly connected drives). - private Func> _rootSource = CamStorage.FindCommonRoots; + /// + /// The source of dashcam roots: auto-discovery by default, or the user's last picked folders. + /// Refresh re-evaluates it to rescan for newly added clips (and, for auto-discovery, newly connected drives). + /// Overridable for tests, which must not depend on the drives of the machine running them. + /// + internal Func> RootSource { get; set; } = CamStorage.FindCommonRoots; // The roots the last scan read, so an empty library can name the folders it looked in. private IReadOnlyList _scannedRoots = []; @@ -137,7 +140,7 @@ whyPermanent is null : $"Permanently delete this clip?\n\nIt can't go to the Recycle Bin because {whyPermanent}, so it can't be recovered once deleted.\n\n{clip.Name}\n{clip.FullPath}"; /// Rescans the current source of dashcam roots (auto-discovered drives, or the folders the user picked). - public Task ReloadAsync(TimeSpan minimumLoadingDuration = default) => LoadClipsAsync(_rootSource(), minimumLoadingDuration); + public Task ReloadAsync(TimeSpan minimumLoadingDuration = default) => LoadClipsAsync(RootSource(), minimumLoadingDuration); public async Task LoadClipsAsync(IEnumerable roots, TimeSpan minimumLoadingDuration = default) { @@ -424,7 +427,7 @@ private async Task OpenFolderAsync() await _playback.StopPlayerAsync(); - _rootSource = () => folders; + RootSource = () => folders; await LoadClipsAsync(folders); } else @@ -438,8 +441,27 @@ private async Task RefreshClipsAsync() { Log.Debug("Refreshing clips"); + // A rescan is for picking up newly copied footage, so the clip being reviewed carries on where it was rather than closing and leaving the user to find it and seek back. + // Only a clip still in the player is reopened: one the user stopped stays closed. + var openClipPath = SelectedClip is { } selected && ReferenceEquals(selected, _playback.NowPlayingClip) ? selected.FullPath : null; + var place = _playback.CurrentPlace; + await _playback.StopPlayerAsync(); await ReloadAsync(TimeSpan.FromMilliseconds(400)); + + // Opening a clip clears the notice over the video, which would hide why another folder's clips are missing. + if (openClipPath is null || _error.IsVisible) + { + return; + } + + // The scan read every clip into a new object, so the open clip is found again by its folder. + var rescanned = _allClips.FirstOrDefault(clip => string.Equals(clip.FullPath, openClipPath, StringComparison.OrdinalIgnoreCase)); + if (rescanned is not null) + { + _playback.ResumeAt(place); + SelectedClip = rescanned; + } } private bool CanRefreshClips => !IsLoadingClips; diff --git a/SentryDeck/ViewModels/PlaybackPlace.cs b/SentryDeck/ViewModels/PlaybackPlace.cs new file mode 100644 index 0000000..236a4f9 --- /dev/null +++ b/SentryDeck/ViewModels/PlaybackPlace.cs @@ -0,0 +1,11 @@ +namespace SentryDeck; + +/// +/// Where the viewer is in an open clip, so the clip can be opened there again. +/// The clip is named by its folder rather than held, because a rescan reads every clip into a new object. +/// +/// The clip's folder. +/// The playhead, in media time. +/// Whether the clip was playing rather than paused. +/// The camera, or the grid, on screen. +public sealed record PlaybackPlace(string ClipPath, TimeSpan Position, bool IsPlaying, string CameraView); diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index b81e13d..03c82e4 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -21,6 +21,9 @@ public sealed partial class PlaybackViewModel : ObservableObject private CancellationTokenSource _selectionCts; private bool _isSeeking; + // Where the next selection picks up instead of just before its event (see ResumeAt), or null. + private PlaybackPlace _resumePlace; + // Identifies the current seek gesture. // EndSeekAsync's slow tail (the accurate seek can queue behind in-flight scrubs) may complete after the user has already started a NEW drag; only the completion belonging to the latest gesture may clear _isSeeking, or the position sync would yank the thumb out from under the active drag. private int _seekGeneration; @@ -222,7 +225,15 @@ public void Select(CamClip clip) "Selected clip changed. ClipName={ClipName}; ClipPath={ClipPath}", clip.Name, clip.FullPath); - _ = PlaySelectedClipAsync(clip, _selectionCts.Token); + + var resumePlace = TakeResumePlace(clip); + if (resumePlace is not null) + { + // Nothing was selected while the clip was reread, so the tiles fell back to the classic four cameras and a B-pillar view dropped to the front. + _cameras.SelectCameraViewCommand.Execute(resumePlace.CameraView); + } + + _ = PlaySelectedClipAsync(clip, resumePlace, _selectionCts.Token); } } @@ -260,6 +271,25 @@ public void SetNavigationFilter(Func canNavigateTo) public Task ReopenAsync(CamClip clip, TimeSpan position, bool play) => _playerController?.ReopenAsync(clip, position, play) ?? Task.CompletedTask; + /// Where the viewer is in the open clip, or null while no clip is open. + public PlaybackPlace CurrentPlace => _playerController is { IsMediaOpen: true, CurrentClip: { } clip } controller + ? new PlaybackPlace(clip.FullPath, controller.Position, controller.IsPlaying, _cameras.SelectedCameraView) + : null; + + /// + /// Has the next selection open at if it is the same clip folder, instead of just before its event. + /// A rescan reads every clip into a new object, and selecting the open clip's new object would otherwise start it over. + /// + public void ResumeAt(PlaybackPlace place) => _resumePlace = place; + + // A place only applies to the selection right after it, so picking that clip again later starts it over as usual. + private PlaybackPlace TakeResumePlace(CamClip clip) + { + var place = _resumePlace; + _resumePlace = null; + return string.Equals(place?.ClipPath, clip.FullPath, StringComparison.OrdinalIgnoreCase) ? place : null; + } + public Task TogglePlayPauseAsync() => _playerController?.TogglePlayPauseAsync() ?? Task.CompletedTask; public Task StepFrameAsync(bool forward) => _playerController?.StepFrameAsync(forward) ?? Task.CompletedTask; @@ -414,7 +444,7 @@ partial void OnPlaybackSpeedChanged(double value) } } - private async Task PlaySelectedClipAsync(CamClip clip, CancellationToken cancellationToken) + private async Task PlaySelectedClipAsync(CamClip clip, PlaybackPlace resumePlace, CancellationToken cancellationToken) { if (clip is null || _playerController is null) return; @@ -437,9 +467,17 @@ private async Task PlaySelectedClipAsync(CamClip clip, CancellationToken cancell try { - await _playerController.GoToClipAsync(clip); + if (resumePlace is null) + { + await _playerController.GoToClipAsync(clip); + } + else + { + await _playerController.GoToClipAsync(clip, resumePlace.Position, resumePlace.IsPlaying); + } - if (cancellationToken.IsCancellationRequested) + // A clip picked up where the viewer was stays on the camera they were watching. + if (cancellationToken.IsCancellationRequested || resumePlace is not null) return; // Auto-focus the camera that triggered the event (Full metadata mode). From d38d39bd3b6d50da2ec59a518ff86b47dae47aae Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:14:13 -0500 Subject: [PATCH 28/73] Find clips by the dates the list shows and by several words at once Searching "saved" on a real Tesla drive also listed every Honk and Alert clip, and a word from the folder the footage was copied to (such as "Sentry" in "D:\Sentry backups") matched every clip. Typing a date or time the way a row or day header shows it ("Dec 16", "Tue", "3:53 PM", "October 30") found nothing, and a two-word query like "hutto honk", or a trailing space, emptied the list. Search compared the whole query, untrimmed, as one substring against the clip's absolute path, its hidden invariant name, the city, and the reason. Each word of the search now has to match some part of the clip: its own folder name, its name, the city, the reason, or the date, time, and day header the list shows. A day typed after a month name stays with it, so "October 30" finds that date and not other October days with a time like 5:30 PM. The list and the search now take those dates from one shared helper, so they read the same in the user's culture. A pasted "Copy path" value still finds its clip as one term, spaces and all, and it is the only search that looks at the folders above the clip. Found in v1.1.0 local testing: F79, F80, F81. --- .../MainWindowViewModelTests.Clips.cs | 179 +++++++++++++++++- SentryDeck/ClipConverters.cs | 16 +- SentryDeck/ClipDisplay.cs | 39 ++++ SentryDeck/ViewModels/ClipLibraryViewModel.cs | 75 +++++++- 4 files changed, 284 insertions(+), 25 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index a3c382e..d6eeac9 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IO; using System.Windows.Input; using CommunityToolkit.Mvvm.Input; @@ -22,13 +23,18 @@ public async Task FilteredClips_OrderNewestFirst() [Fact] public async Task FilteredClips_FiltersByNameCaseInsensitively() { - var clips = TestClips.Create(3); + // Named apart from their dates: each word of a search matches on its own, so the "1" of "Clip 1" would also match every row whose time has a 1 in it. + var clips = new List + { + ClipWithEvent("Driveway", reason: null, city: null), + ClipWithEvent("Parking lot", reason: null, city: null), + }; var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); await vm.Library.LoadClipsAsync(new[] { "root" }); - vm.Library.FilterText = "clip 1"; + vm.Library.FilterText = "PARKING LOT"; - vm.Library.FilteredClips.Single().Name.ShouldBe("Clip 1"); + vm.Library.FilteredClips.Single().Name.ShouldBe("Parking lot"); } [Fact] @@ -44,6 +50,162 @@ public async Task FilteredClips_FiltersByPath() vm.Library.FilteredClips.Count.ShouldBe(2); } + // A clip where Tesla puts it: its own folder sits under SavedClips, below whatever folder the drive was copied to. + private static CamClip SavedClip(string copiedTo, DateTime timestamp, string reason, string city = null) + { + var folder = $"{timestamp:yyyy-MM-dd_HH-mm-ss}"; + return new CamClip( + Path.Combine(copiedTo, "TeslaCam", "SavedClips", folder), + timestamp.ToString(CultureInfo.InvariantCulture), + timestamp, + [], + new CamEvent { Reason = reason, City = city, Timestamp = timestamp }); + } + + private static readonly DateTime Dec16 = new(2025, 12, 16, 15, 53, 27); + private static readonly DateTime Oct30 = new(2025, 10, 30, 8, 5, 0); + + [Fact] + public async Task FilteredClips_WordsInTheFoldersAboveTheClip_DontMatch() + { + var honk = SavedClip(@"D:\Sentry backups", Dec16, "user_interaction_honk"); + var saved = SavedClip(@"D:\Sentry backups", Oct30, "user_interaction_dashcam_launcher_action_tapped"); + var vm = await LoadedViewModelAsync([honk, saved]); + + // Every clip Tesla saves sits under SavedClips, so matching the whole path returned the Honk clips for "saved" too. + Search(vm, "saved"); + vm.Library.FilteredClips.ShouldBe([saved]); + + // A word in the folder the drive was copied to matched every clip in it. + Search(vm, "sentry"); + vm.Library.FilteredClips.ShouldBeEmpty(); + + // The clip's own folder name still counts, so a date typed the way Tesla names its folders finds the clip. + Search(vm, "2025-12-16"); + vm.Library.FilteredClips.ShouldBe([honk]); + } + + [Fact] + public async Task FilteredClips_PastedPathWithASpace_FindsTheClip() + { + var honk = SavedClip(@"D:\Sentry backups", Dec16, "user_interaction_honk"); + var saved = SavedClip(@"D:\Sentry backups", Oct30, "user_interaction_dashcam_launcher_action_tapped"); + var vm = await LoadedViewModelAsync([honk, saved]); + + // A "Copy path" value is one term even though it has a space in it, and it is the one search that reaches the folders above the clip. + Search(vm, honk.FullPath); + + vm.Library.FilteredClips.ShouldBe([honk]); + } + + [Theory] + [InlineData("hutto honk", "A")] + [InlineData(" honk hutto ", "A")] + [InlineData("honk ", "A,C")] + public async Task FilteredClips_SeveralWords_MustEachMatchSomePartOfTheClip(string query, string expected) + { + var vm = await LoadedViewModelAsync( + [ + ClipWithEvent("A", "user_interaction_honk", "Hutto"), + ClipWithEvent("B", "user_interaction_dashcam_launcher_action_tapped", "Hutto"), + ClipWithEvent("C", "user_interaction_honk", "Kyle"), + ]); + + // The whole query used to be one substring, so a city and a reason together, or a stray space, emptied the list. + Search(vm, query); + + vm.Library.FilteredClips.Select(clip => clip.Name).ShouldBe(expected.Split(',')); + } + + [Theory] + [InlineData("date")] + [InlineData("time")] + public async Task FilteredClips_TheDateOrTimeOnTheRow_FindsTheClip(string part) + { + var shown = SavedClip(@"D:\", Dec16, "user_interaction_honk"); + var vm = await LoadedViewModelAsync([shown, SavedClip(@"D:\", Oct30, "user_interaction_honk")]); + + // Taken from the row's own converter, so this holds in whatever culture the list is drawn in. + Search(vm, (string)new FriendlyDateConverter().Convert(shown.Timestamp, typeof(string), part, null)); + + vm.Library.FilteredClips.ShouldBe([shown]); + } + + [Fact] + public async Task FilteredClips_ADayHeader_FindsThatDaysClips() + { + var older = SavedClip(@"D:\", Dec16, "user_interaction_honk"); + var today = SavedClip(@"D:\", DateTime.Today.AddHours(9), "user_interaction_honk"); + var vm = await LoadedViewModelAsync([older, today]); + var header = new DayGroupHeaderConverter(); + + Search(vm, (string)header.Convert(older.Timestamp.Date, typeof(string), null, null)); + vm.Library.FilteredClips.ShouldBe([older]); + + Search(vm, (string)header.Convert(today.Timestamp.Date, typeof(string), null, null)); + vm.Library.FilteredClips.ShouldBe([today]); + } + + [Theory] + [InlineData("Dec 16")] + [InlineData("tue")] + [InlineData("3:53 PM")] + [InlineData("December")] + public async Task FilteredClips_PartOfADateTypedAsTheListShowsIt_FindsTheClip(string query) + { + // Pinned to US English so the examples read as they do on screen there; the tests above cover the user's own culture. + var culture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + try + { + var shown = SavedClip(@"D:\", Dec16, "user_interaction_honk"); + var vm = await LoadedViewModelAsync([shown, SavedClip(@"D:\", Oct30, "user_interaction_honk")]); + + // Only the hidden invariant name ("12/16/2025 15:53:27") and the folder name used to be searched, so "Dec 16" or "3:53 PM" found nothing. + Search(vm, query); + + vm.Library.FilteredClips.ShouldBe([shown]); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } + + [Theory] + [InlineData("October 30", "Oct30")] + [InlineData("oct 30", "Oct30")] + [InlineData("Thu, Oct 30", "Oct30")] + [InlineData("October 30, 2025", "Oct30")] + [InlineData("October 30 honk", "Oct30")] + [InlineData("October 2025", "Oct30,Oct4")] + public async Task FilteredClips_AMonthAndDay_FindsOnlyThatDay(string query, string expected) + { + // Pinned to US English so the month names read as they do on screen there. + var culture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-US"); + try + { + // Other days that month whose times have a 30 in them. + var clips = new Dictionary + { + ["Oct30"] = SavedClip(@"D:\", new DateTime(2025, 10, 30, 20, 57, 0), "user_interaction_honk"), + ["Oct4"] = SavedClip(@"D:\", new DateTime(2025, 10, 4, 17, 30, 0), "user_interaction_honk"), + ["Oct16"] = SavedClip(@"D:\", new DateTime(2024, 10, 16, 11, 30, 0), "user_interaction_honk"), + }; + var vm = await LoadedViewModelAsync([.. clips.Values]); + + // Each word matched on its own, so the "30" of "October 30" also matched the minutes of 5:30 PM and 11:30 AM. + Search(vm, query); + + vm.Library.FilteredClips.ShouldBe(expected.Split(',').Select(key => clips[key])); + } + finally + { + CultureInfo.CurrentCulture = culture; + } + } + [Fact] public async Task FilteredClips_FiltersByCity() { @@ -98,15 +260,20 @@ public async Task FilteredClips_SavedClipWithoutEventJson_IsFoundAsSavedNotRecen [Fact] public async Task ClipCount_ReflectsFilteredCount() { - var clips = TestClips.Create(3); + var clips = new List + { + ClipWithEvent("A", "user_interaction_honk", "Hutto"), + ClipWithEvent("B", "user_interaction_honk", "Kyle"), + ClipWithEvent("C", "user_interaction_honk", "Hutto"), + }; var vm = new MainWindowViewModel(() => null!, clipLoader: _ => clips); await vm.Library.LoadClipsAsync(new[] { "root" }); vm.Library.ClipCount.ShouldBe(3); - vm.Library.FilterText = "Clip 1"; + vm.Library.FilterText = "hutto"; - vm.Library.ClipCount.ShouldBe(1); + vm.Library.ClipCount.ShouldBe(2); } [Fact] diff --git a/SentryDeck/ClipConverters.cs b/SentryDeck/ClipConverters.cs index 9148b59..993b192 100644 --- a/SentryDeck/ClipConverters.cs +++ b/SentryDeck/ClipConverters.cs @@ -53,12 +53,11 @@ public object Convert(object value, Type targetType, object parameter, CultureIn if (value is not DateTime dt) return string.Empty; - var c = CultureInfo.CurrentCulture; return (parameter as string)?.ToLowerInvariant() switch { - "date" => dt.ToString("ddd, MMM d", c), - "time" => dt.ToString("t", c), - _ => $"{dt.ToString("ddd, MMM d", c)} {dt.ToString("t", c)}", + "date" => ClipDisplay.RowDate(dt), + "time" => ClipDisplay.RowTime(dt), + _ => $"{ClipDisplay.RowDate(dt)} {ClipDisplay.RowTime(dt)}", }; } @@ -78,14 +77,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn if (value is not DateTime dt) return string.Empty; - var date = dt.Date; - var today = DateTime.Today; - if (date == today) - return "Today"; - if (date == today.AddDays(-1)) - return "Yesterday"; - - return date.ToString("dddd, MMMM d, yyyy", CultureInfo.CurrentCulture); + return ClipDisplay.DayHeader(dt); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/SentryDeck/ClipDisplay.cs b/SentryDeck/ClipDisplay.cs index af37cb5..fa8c204 100644 --- a/SentryDeck/ClipDisplay.cs +++ b/SentryDeck/ClipDisplay.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace SentryDeck; /// @@ -81,4 +83,41 @@ private static string ReasonKey(CamEvent camEvent, CamSourceFolder sourceFolder) /// public static bool HasLocation(CamEvent camEvent) => camEvent is not null && (camEvent.EstLat != 0 || camEvent.EstLon != 0); + + // The clip list and its search both take dates from here, so a date the user reads on a row or a day header always finds that clip. + + /// + /// The date on a clip's row, e.g. "Tue, Dec 16". + /// + public static string RowDate(DateTime timestamp) => timestamp.ToString("ddd, MMM d", CultureInfo.CurrentCulture); + + /// + /// The time on a clip's row, e.g. "3:53 PM". + /// + public static string RowTime(DateTime timestamp) => timestamp.ToString("t", CultureInfo.CurrentCulture); + + /// + /// The full date, e.g. "Tuesday, December 16, 2025", which heads every day before yesterday. + /// + public static string LongDate(DateTime timestamp) => timestamp.ToString("dddd, MMMM d, yyyy", CultureInfo.CurrentCulture); + + /// + /// The header over a day's clips: "Today", "Yesterday", or the full date. + /// + public static string DayHeader(DateTime timestamp) + { + var date = timestamp.Date; + var today = DateTime.Today; + if (date == today) + { + return "Today"; + } + + if (date == today.AddDays(-1)) + { + return "Yesterday"; + } + + return LongDate(date); + } } diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index b30b8b8..5352527 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -222,21 +222,82 @@ private void ShowNoClipsFound() isEmptyState: true); } - // Matches the clip name, path, event city, and friendly event reason (e.g. "sentry", "honk", "saved"). + // Each word of the search can match a different part of the clip, so "hutto honk" finds the honks in Hutto and a stray space doesn't empty the list. private bool MatchesFilter(CamClip clip) { - if (string.IsNullOrWhiteSpace(FilterText)) + var query = FilterText?.Trim(); + if (string.IsNullOrEmpty(query)) { return true; } - var term = FilterText; - return clip.Name.Contains(term, StringComparison.CurrentCultureIgnoreCase) - || clip.FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase) - || (clip.Event?.City?.Contains(term, StringComparison.CurrentCultureIgnoreCase) ?? false) - || ClipDisplay.ReasonLabel(clip).Contains(term, StringComparison.CurrentCultureIgnoreCase); + // A path pasted from "Copy path" is one term, spaces and all, and it is the only search that should reach the parent folders. + if (Path.IsPathRooted(query) && clip.FullPath.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var searchable = SearchableText(clip); + return SearchTerms(query) + .All(term => searchable.Any(text => text?.Contains(term, StringComparison.CurrentCultureIgnoreCase) ?? false)); } + /// + /// The parts of a search that must each match some part of the clip: its words, except that a day typed after a month stays with it, as in "October 30". + /// On its own, the "30" of "October 30" also matched the minutes of a time like "5:30 PM", which listed every other day that month with such a time. + /// The list always shows the month before the day ("Oct 30", "October 30, 2025"), so the pair is matched as the list writes it. + /// + private static IEnumerable SearchTerms(string query) + { + var words = query.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + for (var index = 0; index < words.Length; index++) + { + if (index + 1 < words.Length && IsMonthName(words[index]) && IsDayOfMonth(words[index + 1])) + { + yield return $"{words[index]} {words[index + 1]}"; + index++; + } + else + { + yield return words[index]; + } + } + } + + // Compared with the names the list writes dates with, which some cultures inflect next to a day number. + private static bool IsMonthName(string word) + { + var format = CultureInfo.CurrentCulture.DateTimeFormat; + return format.MonthNames + .Concat(format.AbbreviatedMonthNames) + .Concat(format.MonthGenitiveNames) + .Concat(format.AbbreviatedMonthGenitiveNames) + .Any(name => name.Length > 0 && string.Equals(name, word, StringComparison.CurrentCultureIgnoreCase)); + } + + // A year such as the 2025 of "October 2025" isn't a day, so that search still finds the whole month. + private static bool IsDayOfMonth(string word) + { + var digits = word.TrimEnd(',', '.'); + return digits.Length is 1 or 2 && digits.All(char.IsAsciiDigit); + } + + /// + /// What a search looks through: the dates, city, and reason the clip's row and day header show, plus the clip's own folder name (e.g. "2025-12-16_15-53-27") and name, so a copied timestamp or folder name still finds it. + /// The folders above the clip are left out because every clip under SavedClips, or under a backup folder named for Sentry, would match those words. + /// + private static string[] SearchableText(CamClip clip) => + [ + clip.Name, + Path.GetFileName(Path.TrimEndingDirectorySeparator(clip.FullPath)), + clip.Event?.City, + ClipDisplay.ReasonLabel(clip), + ClipDisplay.RowDate(clip.Timestamp), + ClipDisplay.RowTime(clip.Timestamp), + ClipDisplay.DayHeader(clip.Timestamp), + ClipDisplay.LongDate(clip.Timestamp), + ]; + private bool IsShownInList(CamClip clip) => clip is not null && MatchesFilter(clip); // Restart the debounce on each keystroke; the list is rebound once typing settles. From 72fad25b4b2caa50bc7b88afda851dd4a0f504c5 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:22:26 -0500 Subject: [PATCH 29/73] Remember the picked dashcam folders across restarts Footage copied to the PC, or on any drive that isn't removable, was gone again after every restart: the app opened on "No dashcam footage yet" and the folder had to be picked again each launch. Copying footage off the car's USB drive is common because the car reformats it, so this hit anyone who kept their clips on the PC. Auto-discovery only looks next to the app and on removable drives, and the picked folders were held only in memory. Picking folders now saves them to settings.json under %LOCALAPPDATA%\SentryDeck, and the next launch loads the ones that still exist. Each window reads them once, at its first scan, so a rescan keeps its own folders even after another window picks different ones. A remembered folder that is gone, such as one on a drive that isn't plugged in, is skipped without an error, and when none are left the app auto-discovers drives as before. The settings file never stops the app: one that can't be read or written is logged and treated as empty. Found in v1.1.0 local testing: lead #1. --- .../MainWindowViewModelTests.Clips.cs | 115 ++++++++++++++++++ SentryDeck.Tests/SettingsStoreTests.cs | 56 +++++++++ SentryDeck/Services/SettingsStore.cs | 76 ++++++++++++ SentryDeck/ViewModels/ClipLibraryViewModel.cs | 68 ++++++++++- 4 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 SentryDeck.Tests/SettingsStoreTests.cs create mode 100644 SentryDeck/Services/SettingsStore.cs diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index d6eeac9..4eea117 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -690,6 +690,121 @@ public async Task LoadClips_OverlappingFolders_ScansEachFolderOnce(string[] pick vm.Library.ClipCount.ShouldBe(expectedScans.Length); } + // --- Remembered folders: the folders picked last time are loaded again at launch, since auto-discovery only finds USB drives. --- + + // A fresh launch of the app that keeps its settings in the given store and would auto-discover the given folders. + private static (MainWindowViewModel Vm, List Scanned) Launch(SettingsStore settings, params string[] discovered) + { + var scanned = new List(); + var vm = new MainWindowViewModel(() => null!, clipLoader: root => + { + scanned.Add(root); + return [ClipAt(Path.Combine(root, "clip"), root, new DateTime(2025, 1, 1))]; + }); + vm.Library.Settings = settings; + vm.Library.DiscoverRoots = () => discovered; + return (vm, scanned); + } + + private static string FolderThatIsGone() => Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}"); + + [Fact] + public async Task Reload_AfterPickingAFolder_TheNextLaunchLoadsItAgain() + { + using var settingsFolder = new TempDirectory(); + using var footage = new TempDirectory(); + var settings = new SettingsStore(Path.Combine(settingsFolder.Path, "settings.json")); + var (firstLaunch, _) = Launch(settings); + await firstLaunch.Library.OpenPickedFoldersAsync([footage.Path]); + + var (vm, scanned) = Launch(settings, @"E:\TeslaCam"); + await vm.Library.ReloadAsync(); + + // Footage copied to the PC isn't on a USB drive, so it was gone after every restart until the folder was picked again. + scanned.ShouldBe([footage.Path]); + vm.Library.ClipCount.ShouldBe(1); + } + + [Fact] + public async Task Reload_ARememberedFolderIsGone_LoadsTheOthersWithoutAnError() + { + using var settingsFolder = new TempDirectory(); + using var footage = new TempDirectory(); + var settings = new SettingsStore(Path.Combine(settingsFolder.Path, "settings.json")); + settings.SavePickedFolders([FolderThatIsGone(), footage.Path]); + + var (vm, scanned) = Launch(settings); + await vm.Library.ReloadAsync(); + + // A remembered folder on a drive that isn't plugged in is no reason to greet the launch with an error. + scanned.ShouldBe([footage.Path]); + vm.Error.IsVisible.ShouldBeFalse(); + } + + [Fact] + public async Task Reload_NoRememberedFolderIsThere_FindsDrivesAsBefore() + { + using var settingsFolder = new TempDirectory(); + var settings = new SettingsStore(Path.Combine(settingsFolder.Path, "settings.json")); + settings.SavePickedFolders([FolderThatIsGone()]); + + var (vm, scanned) = Launch(settings, @"E:\TeslaCam"); + await vm.Library.ReloadAsync(); + + scanned.ShouldBe([@"E:\TeslaCam"]); + } + + [Fact] + public async Task Reload_NothingWasEverPicked_FindsDrives() + { + using var settingsFolder = new TempDirectory(); + var (vm, scanned) = Launch(new SettingsStore(Path.Combine(settingsFolder.Path, "settings.json")), @"E:\TeslaCam"); + + await vm.Library.ReloadAsync(); + + scanned.ShouldBe([@"E:\TeslaCam"]); + } + + [Fact] + public async Task Reload_AnotherWindowPickedAFolderSinceLaunch_RescansTheRememberedFolderAgain() + { + using var settingsFolder = new TempDirectory(); + using var footage = new TempDirectory(); + using var otherFootage = new TempDirectory(); + var settings = new SettingsStore(Path.Combine(settingsFolder.Path, "settings.json")); + settings.SavePickedFolders([footage.Path]); + var (vm, scanned) = Launch(settings); + await vm.Library.ReloadAsync(); + + // Every window keeps its settings in the same file, so a second window's pick replaces the folder remembered there. + var (otherWindow, _) = Launch(settings); + await otherWindow.Library.OpenPickedFoldersAsync([otherFootage.Path]); + + await vm.Library.ReloadAsync(); + + // Rescanning swapped this window's list for the other window's folder, and closed the clip playing here, without a word. + scanned.ShouldBe([footage.Path, footage.Path]); + } + + [Fact] + public async Task Reload_ARememberedFolderMissingAtLaunchIsBack_LoadsItToo() + { + using var settingsFolder = new TempDirectory(); + using var footage = new TempDirectory(); + using var drive = new TempDirectory(); + var unplugged = Path.Combine(drive.Path, "TeslaCam"); + var settings = new SettingsStore(Path.Combine(settingsFolder.Path, "settings.json")); + settings.SavePickedFolders([unplugged, footage.Path]); + var (vm, scanned) = Launch(settings); + await vm.Library.ReloadAsync(); + + // The drive holding the other remembered folder is plugged in after launch. + Directory.CreateDirectory(unplugged); + await vm.Library.ReloadAsync(); + + scanned.ShouldBe([footage.Path, unplugged, footage.Path]); + } + // --- Next/Previous with a search: they walk the clips the list shows, so the list can highlight every clip they open. --- // A clip recorded in a city on 2025-08-08 at the given hour, so a search for the city shows it and hides the others. diff --git a/SentryDeck.Tests/SettingsStoreTests.cs b/SentryDeck.Tests/SettingsStoreTests.cs new file mode 100644 index 0000000..12aa1c0 --- /dev/null +++ b/SentryDeck.Tests/SettingsStoreTests.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Text; + +namespace SentryDeck.Tests; + +public sealed class SettingsStoreTests +{ + [Fact] + public void LoadPickedFolders_NothingSavedYet_ReturnsNone() + { + using var temp = new TempDirectory(); + + new SettingsStore(Path.Combine(temp.Path, "settings.json")).LoadPickedFolders().ShouldBeEmpty(); + } + + [Fact] + public void SavePickedFolders_ThenLoadInANewStore_ReturnsTheSameFolders() + { + using var temp = new TempDirectory(); + + // The app's own folder under %LOCALAPPDATA% doesn't exist until the first save creates it. + var path = Path.Combine(temp.Path, "SentryDeck", "settings.json"); + new SettingsStore(path).SavePickedFolders([@"D:\TeslaCam", @"E:\Footage\TeslaCam"]); + + // A new store reads them from disk, as the next launch would. + new SettingsStore(path).LoadPickedFolders().ShouldBe([@"D:\TeslaCam", @"E:\Footage\TeslaCam"]); + } + + [Theory] + [InlineData("")] + [InlineData("not json")] + [InlineData("null")] + [InlineData("{\"PickedFolders\": 5}")] + public void LoadPickedFolders_FileIsDamaged_ReturnsNone(string content) + { + using var temp = new TempDirectory(); + var path = temp.Write("settings.json", Encoding.UTF8.GetBytes(content)); + + // A damaged file only costs picking the folder again, but throwing here would stop the app from starting. + new SettingsStore(path).LoadPickedFolders().ShouldBeEmpty(); + } + + [Fact] + public void SavePickedFolders_FileCantBeWritten_DoesNotThrow() + { + using var temp = new TempDirectory(); + + // A file where the settings folder belongs makes the save fail. + var notAFolder = temp.Write("SentryDeck"); + var store = new SettingsStore(Path.Combine(notAFolder, "settings.json")); + + // Failing to remember the pick must not keep the folder just picked from opening. + Should.NotThrow(() => store.SavePickedFolders([@"D:\TeslaCam"])); + store.LoadPickedFolders().ShouldBeEmpty(); + } +} diff --git a/SentryDeck/Services/SettingsStore.cs b/SentryDeck/Services/SettingsStore.cs new file mode 100644 index 0000000..51436f5 --- /dev/null +++ b/SentryDeck/Services/SettingsStore.cs @@ -0,0 +1,76 @@ +using System.IO; +using System.Text.Json; +using Serilog; + +namespace SentryDeck; + +/// +/// Keeps the user's choices across restarts in a small JSON file under %LOCALAPPDATA%\SentryDeck. +/// Reading and writing never throw, because a lost setting only means picking the folder again, while an exception here would stop the app from starting or the picked folder from opening. +/// +public sealed class SettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + }; + + /// The settings file. + /// Defaults to settings.json next to the app's logs; tests pass their own so they never read or overwrite the user's settings. + public SettingsStore(string path = null) + { + FilePath = path ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "SentryDeck", + "settings.json"); + } + + public string FilePath { get; } + + /// + /// The dashcam folders the user last picked, or none when they never picked one or the file can't be read. + /// + public IReadOnlyList LoadPickedFolders() => + Load()?.PickedFolders?.Where(folder => !string.IsNullOrWhiteSpace(folder)).ToList() ?? []; + + public void SavePickedFolders(IEnumerable folders) + { + // Read back first so saving this choice can't wipe out any other setting stored beside it. + var settings = Load() ?? new SettingsFile(); + settings.PickedFolders = [.. folders]; + Save(settings); + } + + private SettingsFile Load() + { + try + { + return File.Exists(FilePath) + ? JsonSerializer.Deserialize(File.ReadAllText(FilePath)) + : null; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to read settings. Path={Path}", FilePath); + return null; + } + } + + private void Save(SettingsFile settings) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)); + File.WriteAllText(FilePath, JsonSerializer.Serialize(settings, JsonOptions)); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to save settings. Path={Path}", FilePath); + } + } + + private sealed class SettingsFile + { + public List PickedFolders { get; set; } + } +} diff --git a/SentryDeck/ViewModels/ClipLibraryViewModel.cs b/SentryDeck/ViewModels/ClipLibraryViewModel.cs index 5352527..ab3334c 100644 --- a/SentryDeck/ViewModels/ClipLibraryViewModel.cs +++ b/SentryDeck/ViewModels/ClipLibraryViewModel.cs @@ -24,15 +24,31 @@ public sealed partial class ClipLibraryViewModel : ObservableObject private readonly DispatcherTimer _filterDebounceTimer; /// - /// The source of dashcam roots: auto-discovery by default, or the user's last picked folders. + /// The source of dashcam roots: the folders the user picked, in this session or an earlier one, or auto-discovery when there are none. /// Refresh re-evaluates it to rescan for newly added clips (and, for auto-discovery, newly connected drives). /// Overridable for tests, which must not depend on the drives of the machine running them. /// - internal Func> RootSource { get; set; } = CamStorage.FindCommonRoots; + internal Func> RootSource { get; set; } + + /// + /// Finds TeslaCam folders on its own: next to the app, and on USB drives. + /// Overridable for tests, which must not depend on the drives of the machine running them. + /// + internal Func> DiscoverRoots { get; set; } = CamStorage.FindCommonRoots; + + /// + /// Remembers the picked folders for the next launch. + /// Overridable for tests, which must not read or overwrite the user's own settings. + /// + internal SettingsStore Settings { get; set; } = new(); // The roots the last scan read, so an empty library can name the folders it looked in. private IReadOnlyList _scannedRoots = []; + // Read once, at the first scan, because every window shares the settings file. + // Reading it again on each rescan swapped this window's folders for whatever another window picked since, and closed the clip playing here. + private readonly Lazy> _rememberedFolders; + /// Maps a dashcam root to its clips. /// The player, which is stopped before a rescan or a delete and follows the clip list's playlist. /// Where scan and delete failures are reported. @@ -46,6 +62,8 @@ public ClipLibraryViewModel( _clipLoader = clipLoader; _playback = playback; _error = error; + _rememberedFolders = new(() => Settings.LoadPickedFolders()); + RootSource = RememberedOrDiscoveredRoots; // Coalesces the expensive clip-list regroup/rebind so fast typing in search stays smooth; the getters stay live, so only the (debounced) change notification is deferred. _filterDebounceTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher) @@ -329,6 +347,34 @@ internal void ApplyFilter() _playback.SetNavigationFilter(MatchesFilter); } + /// + /// The folders picked at an earlier launch that are still there, or the auto-discovered ones when none are. + /// Auto-discovery only looks at USB drives, so footage copied to the PC (common, since the car's drive gets reformatted) had to be picked again on every launch. + /// A remembered folder that is gone, such as one on a drive that isn't plugged in, is skipped rather than reported, so launching without it still opens whatever else is there. + /// Each rescan checks the folders again, so that drive's footage comes back once it is plugged in. + /// Deferred until the scan enumerates it off the UI thread, because checking a folder on a disconnected network share can take seconds. + /// + private IEnumerable RememberedOrDiscoveredRoots() + { + var remembered = new List(); + foreach (var folder in _rememberedFolders.Value) + { + if (Directory.Exists(folder)) + { + remembered.Add(folder); + } + else + { + Log.Information("Skipping remembered dashcam folder that no longer exists. Folder={Folder}", folder); + } + } + + foreach (var root in remembered.Count > 0 ? remembered : DiscoverRoots()) + { + yield return root; + } + } + private ScanResult ScanRoots(IEnumerable roots) { var rootList = WithoutOverlappingRoots(roots?.Where(root => !string.IsNullOrWhiteSpace(root)) ?? []); @@ -486,10 +532,7 @@ private async Task OpenFolderAsync() folders.Length, folders); - await _playback.StopPlayerAsync(); - - RootSource = () => folders; - await LoadClipsAsync(folders); + await OpenPickedFoldersAsync(folders); } else { @@ -497,6 +540,19 @@ private async Task OpenFolderAsync() } } + /// + /// Loads the folders the user picked, and keeps loading them on later launches instead of auto-discovering. + /// Kept apart from the folder dialog so tests can pick folders without one. + /// + internal async Task OpenPickedFoldersAsync(IReadOnlyList folders) + { + await _playback.StopPlayerAsync(); + + Settings.SavePickedFolders(folders); + RootSource = () => folders; + await LoadClipsAsync(folders); + } + [RelayCommand(CanExecute = nameof(CanRefreshClips))] private async Task RefreshClipsAsync() { From b4ee18846363f8261f84123a133af0b807376e3b Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:16:52 -0500 Subject: [PATCH 30/73] Keep the controls working when you click the clip that is still open Clearing a search that had hidden the open clip, then clicking that clip again, left Play/Pause, the seek bar, frame step and Trim greyed out for good, and hiding the clip again showed a "Loading..." overlay that never went away. Selecting a clip marks it as loading and asks the player to move to it, but the player ignores a move to the clip it already has, so nothing ever cleared the loading state. Selecting the clip the player already has now takes the player's own loading state instead: a playing or paused clip carries on where it was, and a stopped or failed one is reopened as a normal selection would. Found in v1.1.0 local testing: F76, F05, F53, F105. --- .../MainWindowViewModelTests.Playback.cs | 58 +++++++++++++++++++ SentryDeck/ViewModels/PlaybackViewModel.cs | 22 ++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 456d268..16535b6 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -271,4 +271,62 @@ public void NextCommand_MovesTheListSelectionToTheNextClip() controller.CurrentClip.ShouldBe(second.Clip); front.Count("open").ShouldBe(2); } + + [Fact] + public void SelectingTheLoadedClipAgain_AfterADeselectWhilePaused_EnablesTheTransportWithoutReopening() + { + // A search that hides the open clip deselects it but leaves it loaded, so clearing the search and clicking it again selects the clip the player already has. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out var controller, out var front); + RunPinnedToTestThread(() => vm.Playback.PlayPauseCommand.ExecuteAsync(null)); + var callsBefore = front.Calls.Count; + + vm.Library.SelectedClip = null; + vm.Library.SelectedClip = clipFiles.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + + vm.Playback.IsLoading.ShouldBeFalse(); + vm.Playback.CanPlayPause.ShouldBeTrue(); + vm.Playback.CanSeek.ShouldBeTrue(); + + // The paused clip stays where the user left it instead of reopening or resuming. + front.Calls.Count.ShouldBe(callsBefore); + vm.Playback.IsPlaying.ShouldBeFalse(); + } + + [Fact] + public void SelectingTheLoadedClipAgain_AfterAStop_ReopensIt() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out var controller, out var front); + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + + vm.Library.SelectedClip = null; + vm.Library.SelectedClip = clipFiles.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + + front.Count("open").ShouldBe(2); + controller.IsPlaying.ShouldBeTrue(); + vm.Playback.IsLoading.ShouldBeFalse(); + vm.Playback.CanPlayPause.ShouldBeTrue(); + vm.Playback.CanSeek.ShouldBeTrue(); + } + + // Selects the clip through the list and waits until the player has it open and playing. + // The view-model's handlers run inline on whichever thread the controller raises them, because the open finishes on a thread-pool continuation. + private MainWindowViewModel CreateViewModelPlayingClip(CamClip clip, out VideoPlayerController controller, out FakeCameraPlayer front) + { + front = new FakeCameraPlayer(); + var built = BuildFourCameraController(front); + controller = built; + built.LoadClips([clip]); + + var vm = new MainWindowViewModel(() => built, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + vm.Library.SelectedClip = clip; + RunPinnedToTestThread(built.WhenIdleAsync); + + built.IsPlaying.ShouldBeTrue(); + return vm; + } } diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index 03c82e4..f042100 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -467,7 +467,11 @@ private async Task PlaySelectedClipAsync(CamClip clip, PlaybackPlace resumePlace try { - if (resumePlace is null) + if (clip == _playerController.CurrentClip) + { + await ResyncWithLoadedClipAsync(cancellationToken); + } + else if (resumePlace is null) { await _playerController.GoToClipAsync(clip); } @@ -509,6 +513,22 @@ private async Task PlaySelectedClipAsync(CamClip clip, PlaybackPlace resumePlace } } + // The playlist ignores a move to the clip it already has, so selecting the clip that is still loaded (after a search hid it, or a deselect) opens nothing, and no loading change would ever arrive to clear IsLoading and re-enable the transport. + // A paused or playing clip carries on where it was; only a stopped or failed one is reopened, as selecting it would normally do. + private async Task ResyncWithLoadedClipAsync(CancellationToken cancellationToken) + { + if (!_playerController.IsMediaOpen) + { + await _playerController.PlayAsync(); + } + + // A newer selection owns IsLoading now, and its own load sets it. + if (cancellationToken.IsCancellationRequested) + return; + + IsLoading = _playerController.IsLoading; + } + private async Task SeekToCurrentPositionAsync() { if (_playerController is null) From cc86b9b6e42a1a65ed66d1c6b35637b7300c7cbb Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:24:06 -0500 Subject: [PATCH 31/73] Let Stop unload a paused or finished clip Once a clip was paused, or had played to the end, the Stop button greyed out even though the clip was still open and holding its files, so the only way to stop it was to press Play and then Stop. Stop was enabled only while playing or loading, although stopping works from any state. Stop is now enabled whenever media is open, and it greys out again as soon as the clip is stopped. Found in v1.1.0 local testing: F03, F12. --- .../MainWindowViewModelTests.Playback.cs | 32 +++++++++++++++++++ SentryDeck/ViewModels/PlaybackViewModel.cs | 4 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 16535b6..5458b94 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -312,6 +312,38 @@ public void SelectingTheLoadedClipAgain_AfterAStop_ReopensIt() vm.Playback.CanSeek.ShouldBeTrue(); } + [Fact] + public void StopButton_WhilePaused_StaysEnabledUntilTheClipIsStopped() + { + // A paused clip still holds its files open, and Stop is how the user lets go of them without playing on first. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out _, out _); + RunPinnedToTestThread(() => vm.Playback.PlayPauseCommand.ExecuteAsync(null)); + vm.Playback.IsPlaying.ShouldBeFalse(); + vm.Playback.CanStop.ShouldBeTrue(); + + var changed = new List(); + vm.Playback.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + + // Nothing else the button watches changes when a paused clip stops, so it only greys out if this is announced. + changed.ShouldContain(nameof(PlaybackViewModel.CanStop)); + vm.Playback.CanStop.ShouldBeFalse(); + } + + [Fact] + public void StopButton_AfterTheClipPlaysToTheEnd_StaysEnabled() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out var controller, out var front); + + front.RaiseEnded(TimeSpan.FromSeconds(60)); + RunPinnedToTestThread(controller.WhenIdleAsync); + + vm.Playback.IsPlaying.ShouldBeFalse(); + vm.Playback.CanStop.ShouldBeTrue(); + } + // Selects the clip through the list and waits until the player has it open and playing. // The view-model's handlers run inline on whichever thread the controller raises them, because the open finishes on a thread-pool continuation. private MainWindowViewModel CreateViewModelPlayingClip(CamClip clip, out VideoPlayerController controller, out FakeCameraPlayer front) diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index f042100..76a7f81 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -106,7 +106,8 @@ public PlaybackViewModel( public bool CanPlayPause => (SelectedClip is not null || IsPlaying) && !IsLoading; - public bool CanStop => IsPlaying || IsLoading; + // A paused or finished clip still holds its files open, so Stop must stay available to let go of them without resuming first. + public bool CanStop => IsPlaying || IsLoading || _playerController?.IsMediaOpen == true; public bool CanGoNext => _playerController?.CanGoNext == true; @@ -720,6 +721,7 @@ private void HandlePlayerControllerPropertyChanged(string propertyName) break; case nameof(VideoPlayerController.IsMediaOpen): + OnPropertyChanged(nameof(CanStop)); NotifyCanSeekChanged(); break; } From 295f956c5172c99ae6c81202da52d6629b809e93 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:25:14 -0500 Subject: [PATCH 32/73] Show the now-playing badge again when Play restarts a stopped clip After Stop then Play, the clip played again but its row in the list had lost the now-playing badge, and clicking the row didn't bring it back; only picking a different clip did. Stop takes the badge off, and the badge was only put back when the player moved to another clip, but Play reopens the same clip. The badge now follows the player whenever it starts loading a clip, so Play after Stop marks the clip as playing again. Found in v1.1.0 local testing: F07. --- .../MainWindowViewModelTests.Playback.cs | 16 ++++++++++++++++ SentryDeck/ViewModels/PlaybackViewModel.cs | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 5458b94..7fee31d 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -344,6 +344,22 @@ public void StopButton_AfterTheClipPlaysToTheEnd_StaysEnabled() vm.Playback.CanStop.ShouldBeTrue(); } + [Fact] + public void PlayAfterStop_PutsTheNowPlayingBadgeBack() + { + // Play after Stop reopens the clip the player still has, which is not a clip change, so nothing else would mark it as playing again. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out var controller, out _); + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + vm.Playback.NowPlayingClip.ShouldBeNull(); + + RunPinnedToTestThread(() => vm.Playback.PlayPauseCommand.ExecuteAsync(null)); + RunPinnedToTestThread(controller.WhenIdleAsync); + + controller.IsPlaying.ShouldBeTrue(); + vm.Playback.NowPlayingClip.ShouldBe(clipFiles.Clip); + } + // Selects the clip through the list and waits until the player has it open and playing. // The view-model's handlers run inline on whichever thread the controller raises them, because the open finishes on a thread-pool continuation. private MainWindowViewModel CreateViewModelPlayingClip(CamClip clip, out VideoPlayerController controller, out FakeCameraPlayer front) diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index 76a7f81..76a3946 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -675,6 +675,13 @@ private void HandlePlayerControllerPropertyChanged(string propertyName) { case nameof(VideoPlayerController.IsLoading): IsLoading = _playerController.IsLoading; + + // Play after Stop reopens the clip the player still has, which raises no clip change, so the badge Stop took off comes back here. + if (_playerController.IsLoading) + { + NowPlayingClip = _playerController.CurrentClip; + } + break; case nameof(VideoPlayerController.IsPlaying): From 5ca55b342cdcf15346be45009e151bc57a96ad18 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:29:29 -0500 Subject: [PATCH 33/73] Keep the seek-bar markers in place and disable Event after Stop After Stop, the chunk seams and the event marker on the seek bar jumped to positions that didn't match the footage, and on clips whose event lies past the end of the footage a marker appeared that wasn't there while the clip was open. Event (and the E key) stayed enabled too, and pressing it only moved the thumb on the disabled seek bar while nothing was loaded. Stop closes the clip's media, and the markers then fell back to the 60-seconds-per-chunk estimate meant only for the moment before a clip first opens; Event was enabled whenever there was a marker, whether or not anything could seek. The markers measured from a clip's footage now stay until another clip is selected, and Event and the E key need a clip that can seek. Found in v1.1.0 local testing: F56. --- .../MainWindowViewModelTests.Markers.cs | 105 +++++++++++++++++- SentryDeck/MainWindowViewModel.cs | 2 +- SentryDeck/ViewModels/PlaybackViewModel.cs | 24 +++- 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs index 77ea13c..9f86400 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Markers.cs @@ -193,14 +193,114 @@ public void ClearingSelection_ResetsEventMarkerAndChunks() } [Fact] - public void JumpToEvent_CanExecute_FollowsHasEventMarker() + public void JumpToEvent_WithAMarkerButNothingOpen_IsDisabled() { var vm = CreateViewModel(); vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeFalse(); vm.Library.SelectedClip = ClipWithChunksAndEvent(10, TimeSpan.FromSeconds(570)); + // With no media open, a jump could only move the thumb on a disabled seek bar. + vm.Playback.HasEventMarker.ShouldBeTrue(); + vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeFalse(); + } + + [Fact] + public void JumpToEvent_OnceTheSelectedClipOpens_IsAnnouncedAsAvailable() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30)); + var controller = BuildFourCameraController(new FakeCameraPlayer()); + controller.LoadClips([clip]); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + var announced = new List(); + vm.Playback.JumpToEventCommand.CanExecuteChanged += (_, _) => announced.Add(vm.Playback.JumpToEventCommand.CanExecute(null)); + + vm.Library.SelectedClip = clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + + // The Event button only re-reads CanExecute when told to, so the last announcement has to come once the clip can seek. + announced.ShouldNotBeEmpty(); + announced[^1].ShouldBeTrue(); + } + + [Fact] + public void JumpToEvent_AfterStop_IsDisabledAndTheEKeyIsIgnored() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30)); + var (vm, _, _) = CreateViewModelWithOpenedClip(clip); + vm.Library.SelectedClip = clip; vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeTrue(); + + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + + vm.Playback.JumpToEventCommand.CanExecute(null).ShouldBeFalse(); + vm.HandleKeyDown(Key.E, ModifierKeys.None).ShouldBeFalse(); + vm.Playback.SeekPosition.ShouldBe(0); + } + + [Fact] + public void StoppingTheOpenClip_KeepsItsMeasuredMarkers() + { + // Chunks of 60, 60 and 20 seconds put the measured seams at 60/140 and 120/140 and the event at 90/140, where the 60-seconds-per-chunk estimate would put them at thirds and the event at the middle. + using var clipFiles = TestClipFiles.Create( + chunkCount: 3, + chunkDurations: [TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(20)]); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30)); + var (vm, _, _) = CreateViewModelWithOpenedClip(clip); + vm.Library.SelectedClip = clip; + vm.Playback.ChunkBoundaries.ShouldBe([60.0 / 140, 120.0 / 140], 0.0001); + vm.Playback.EventMarkerPosition.ShouldBe(90.0 / 140, 0.0001); + + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + + vm.Playback.ChunkBoundaries.ShouldBe([60.0 / 140, 120.0 / 140], 0.0001); + vm.Playback.HasEventMarker.ShouldBeTrue(); + vm.Playback.EventMarkerPosition.ShouldBe(90.0 / 140, 0.0001); + } + + [Fact] + public void StoppingTheOpenClip_WhenItsEventIsPastTheFootage_ShowsNoMarker() + { + // The estimate models 180 s, but the footage is 140 s long, so an event at 175 s only fits the estimate. + using var clipFiles = TestClipFiles.Create( + chunkCount: 3, + chunkDurations: [TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(20)]); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Chunks[2].Timestamp.AddSeconds(55)); + var (vm, _, _) = CreateViewModelWithOpenedClip(clip); + vm.Library.SelectedClip = clip; + vm.Playback.HasEventMarker.ShouldBeFalse(); + + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + + vm.Playback.HasEventMarker.ShouldBeFalse(); + } + + [Fact] + public void SelectingClipsAfterStop_ShowsEachClipsOwnMarkers() + { + // The kept markers belong to the stopped clip only, and only until another clip replaces them. + using var clipFiles = TestClipFiles.Create( + chunkCount: 3, + chunkDurations: [TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(20)]); + var controller = BuildFourCameraController(new FakeCameraPlayer()); + controller.LoadClips([clipFiles.Clip]); + controller.Playlist.MoveTo(0); + RunPinnedToTestThread(controller.WhenIdleAsync); + + // Selection loads wait at this never-released gate, so each selection's markers are read before any reopen could replace them. + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => new TaskCompletionSource().Task); + vm.InitializePlayer(); + vm.Library.SelectedClip = clipFiles.Clip; + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + + vm.Library.SelectedClip = ClipWithChunks(4); + vm.Playback.ChunkBoundaries.ShouldBe([0.25, 0.5, 0.75], 0.0001); + + vm.Library.SelectedClip = clipFiles.Clip; + vm.Playback.ChunkBoundaries.ShouldBe([1.0 / 3, 2.0 / 3], 0.0001); } [Fact] @@ -250,4 +350,7 @@ public void SelectingClip_RaisesEventMarkerNotifications() changed.ShouldContain(nameof(PlaybackViewModel.HasEventMarker)); changed.ShouldContain(nameof(PlaybackViewModel.ChunkBoundaries)); } + + private static CamClip WithEventAt(CamClip clip, DateTime timestamp) => + new(clip.FullPath, clip.Name, clip.Timestamp, clip.Chunks, new CamEvent { Reason = "user_interaction_honk", Timestamp = timestamp }); } diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index fb94bd4..4cbdc00 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -312,7 +312,7 @@ private Func ResolveKeyAction(Key key, ModifierKeys modifiers) return Run(() => Cameras.SelectCameraViewCommand.Execute(numberedOption.ViewId)); } - if (key == Key.E && Playback.HasEventMarker) + if (key == Key.E && Playback.CanJumpToEvent) { return () => Playback.JumpToEventCommand.ExecuteAsync(null); } diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index 76a3946..f62cf3f 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -34,6 +34,9 @@ public sealed partial class PlaybackViewModel : ObservableObject private IReadOnlyList _chunkBoundaries = []; private IReadOnlyList _gapPositions = []; + // The clip whose opened media the overlays above were measured from, or null while they are an estimate. + private CamClip _measuredOverlaysClip; + /// Creates the playback controller (the view supplies one bound to its Flyleaf hosts). /// Yields to the UI before a clip loads so the window stays responsive. /// Runs an action on the UI thread. @@ -123,6 +126,12 @@ public PlaybackViewModel( /// True when the selected clip has a locatable event moment to mark on the seek bar and jump to. public bool HasEventMarker => _eventPosition.HasValue; + /// + /// True when the player can jump to the event marker. + /// A stopped or still-loading clip has nothing to seek, so a jump would only move the thumb on a disabled seek bar. + /// + public bool CanJumpToEvent => HasEventMarker && CanSeek; + /// /// Friendly reason + time for the event marker tooltip, e.g. "Honk · 3:53 PM". /// The reason comes from the clip rather than its event, so a saved clip whose event.json has no reason is called Saved here, as on its card. @@ -145,8 +154,10 @@ public PlaybackViewModel( [NotifyPropertyChangedFor(nameof(CanPlayPause))] [NotifyPropertyChangedFor(nameof(CanStop))] [NotifyPropertyChangedFor(nameof(CanSeek))] + [NotifyPropertyChangedFor(nameof(CanJumpToEvent))] [NotifyCanExecuteChangedFor(nameof(StepFrameBackwardCommand))] [NotifyCanExecuteChangedFor(nameof(StepFrameForwardCommand))] + [NotifyCanExecuteChangedFor(nameof(JumpToEventCommand))] private bool _isLoading; [ObservableProperty] @@ -394,10 +405,10 @@ private async Task StopAsync() [RelayCommand(CanExecute = nameof(CanSeek))] private Task StepFrameForwardAsync() => StepFrameAsync(forward: true); - [RelayCommand(CanExecute = nameof(HasEventMarker))] + [RelayCommand(CanExecute = nameof(CanJumpToEvent))] private async Task JumpToEventAsync() { - if (!HasEventMarker) + if (!CanJumpToEvent) return; Log.Debug("Jumping to event moment. Position={EventMarkerPosition}", EventMarkerPosition); @@ -552,6 +563,7 @@ private Task ScrubToAsync(TimeSpan position) => // // Prefers the controller's actually-opened ClipMediaSource when it belongs to this clip: that source has real probed durations and gap-aware wall-clock mapping (see ClipMediaSource.ToMediaTime), so its positions match what's actually playing. // Selecting a clip is synchronous but opening its media is not, so immediately after selection (or for a clip that never opens, e.g. in tests with no controller) there is no opened source yet; a ClipTimeline estimate (uniform assumed chunk length) is used as a same-frame placeholder so the markers don't flash empty, and is superseded once OpenedMediaSource changes. + // Once a clip's media has been measured, its overlays outlive that media closing (Stop, or a recovery rebuild in progress): they still describe its footage, whereas the estimate would move the seams and the event marker, or show a marker the footage doesn't reach. private void RecomputeSelectedClipTimeline() { var clip = SelectedClip; @@ -560,6 +572,7 @@ private void RecomputeSelectedClipTimeline() _eventPosition = null; _chunkBoundaries = []; _gapPositions = []; + _measuredOverlaysClip = null; return; } @@ -568,10 +581,12 @@ private void RecomputeSelectedClipTimeline() if (mediaSource is not null && mediaSource.Duration > TimeSpan.Zero) { RecomputeFromMediaSource(clip, mediaSource); + _measuredOverlaysClip = clip; } - else + else if (_measuredOverlaysClip != clip) { RecomputeFromEstimatedTimeline(clip); + _measuredOverlaysClip = null; } } @@ -626,6 +641,7 @@ private void NotifyMarkersChanged() { OnPropertyChanged(nameof(EventMarkerPosition)); OnPropertyChanged(nameof(HasEventMarker)); + OnPropertyChanged(nameof(CanJumpToEvent)); OnPropertyChanged(nameof(EventMarkerTooltip)); OnPropertyChanged(nameof(ChunkBoundaries)); OnPropertyChanged(nameof(GapPositions)); @@ -636,8 +652,10 @@ private void NotifyMarkersChanged() private void NotifyCanSeekChanged() { OnPropertyChanged(nameof(CanSeek)); + OnPropertyChanged(nameof(CanJumpToEvent)); StepFrameBackwardCommand.NotifyCanExecuteChanged(); StepFrameForwardCommand.NotifyCanExecuteChanged(); + JumpToEventCommand.NotifyCanExecuteChanged(); } private void NotifyNavigationChanged() From 7eb400b710d70e9f808b361e54c06ed7dc8e9b83 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:31:03 -0500 Subject: [PATCH 34/73] Close the trim panel when Stop is pressed while trimming Pressing Stop with in/out marks set wiped the marks without warning and left the trim panel open, with Set start, Set end, Export and the Trim button all greyed out, so only Cancel could close it. Stop closes the clip's media, which already discards the marks, but nothing closed the panel the way switching clips does. Stop now closes the trim panel along with the marks, as selecting another clip does. Found in v1.1.0 local testing: F73. --- .../MainWindowViewModelTests.Trimming.cs | 19 +++++++++++++++++++ SentryDeck/MainWindowViewModel.cs | 3 +++ SentryDeck/ViewModels/PlaybackViewModel.cs | 6 ++++++ 3 files changed, 28 insertions(+) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs index 1f54d01..3dad307 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs @@ -177,6 +177,25 @@ public void TrimPanel_ClosesWhenAnotherClipIsSelected() vm.Trim.IsTrimming.ShouldBeFalse(); } + [Fact] + public void StopCommand_WhileTrimming_ClosesTheTrimPanel() + { + // Stop closes the media the marks were set against, and a panel left open over a stopped player could neither mark, export, nor be closed with the Trim button. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip); + vm.Library.SelectedClip = clipFiles.Clip; + vm.Playback.SeekPosition = 0.2; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.4; + vm.Trim.MarkSelectionEndCommand.Execute(null); + vm.Trim.IsTrimming.ShouldBeTrue(); + + RunPinnedToTestThread(() => vm.Playback.StopCommand.ExecuteAsync(null)); + + vm.Trim.IsTrimming.ShouldBeFalse(); + vm.Trim.HasAnySelectionMark.ShouldBeFalse(); + } + [Fact] public void TrimHintText_WalksThroughStartEndExport() { diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index 4cbdc00..3891d92 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -76,6 +76,9 @@ public MainWindowViewModel( Playback.PropertyChanged += OnPlaybackPropertyChanged; Playback.CurrentClipChanged += (_, clip) => Library.SelectedClip = clip; Library.PropertyChanged += OnLibraryPropertyChanged; + + // Stop drops the media the in/out marks were set against, and a trim panel left open over a stopped player could neither mark, export, nor be closed with the Trim button. + Playback.Stopped += (_, _) => Trim.CancelTrimCommand.Execute(null); } /// diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index f62cf3f..c080de3 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -62,6 +62,11 @@ public PlaybackViewModel( /// public event EventHandler CurrentClipChanged; + /// + /// Raised after the user stops playback, so features working on the open media can close along with it. + /// + public event EventHandler Stopped; + /// /// Ladder the speed stepper walks: fine increments around 1x, doubling above. /// Flyleaf clamps Player.Speed to [0.125, 16], so 16x is the hard ceiling. @@ -397,6 +402,7 @@ private async Task StopAsync() IsLoading = false; SeekPosition = 0; NowPlayingClip = null; + Stopped?.Invoke(this, EventArgs.Empty); } [RelayCommand(CanExecute = nameof(CanSeek))] From 89a5a1d3ca3ad5c90daf5fdb18df91b55d67d3a7 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:39:20 -0500 Subject: [PATCH 35/73] Keep the camera view you picked when you move to another clip Choosing Grid, Rear or a repeater lasted only until the next clip: every Next, Previous or click in the list snapped the view back to Front, so comparing one angle across several clips meant pressing the view key again each time. Each clip that opens jumps to the camera its event.json names, and the app treated camera 0 as "the front camera triggered this". Every Dashcam save (honk, launcher tap) reports camera 0, and a missing or unreadable camera field reads as 0 too, so the jump fired on nearly every clip. Camera 0, unknown ids and cameras the clip didn't record now count as no preference and leave the chosen view alone; an event that names a specific side or rear camera still opens on it. Found in v1.1.0 local testing: F61, F43, F55, F117. --- .../MainWindowViewModelTests.Playback.cs | 39 +++++++++++++++++++ SentryDeck.Tests/MainWindowViewModelTests.cs | 17 +++++--- SentryDeck/ViewModels/CameraViewsViewModel.cs | 9 +++-- SentryDeck/ViewModels/PlaybackViewModel.cs | 6 +-- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 7fee31d..3b6a0e1 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -227,6 +227,45 @@ public void SelectingAnEventClip_AutoFocusesTheTriggeringCamera() vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Back); } + [Theory] + [InlineData(0)] // front, and also what a missing or unreadable field reads as + [InlineData(8)] // cabin camera, never written to USB + [InlineData(99)] // unknown id + public void SelectingAnEventClip_ThatNamesNoSideOrRearCamera_KeepsTheChosenView(int eventCamera) + { + var vm = CreateViewModelWithController(out _, out _); + vm.Cameras.SelectCameraViewCommand.Execute(CameraViewsViewModel.GridCameraView); + + // As in SelectingAnEventClip_AutoFocusesTheTriggeringCamera, the clip is deliberately not in the controller's playlist, so the selection load runs inline on this thread. + vm.Library.SelectedClip = ClipWithCamerasAndEventCamera(eventCamera, SixCameras); + + vm.Cameras.SelectedCameraView.ShouldBe(CameraViewsViewModel.GridCameraView); + } + + [Fact] + public void NextCommand_ToAClipWhoseEventReportsCameraZero_KeepsTheChosenView() + { + // Every Dashcam save (honk, launcher tap) reports camera 0, so following it snapped the grid back to Front on every clip change. + using var firstFiles = TestClipFiles.Create(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + var first = WithEventCamera(firstFiles.Clip, eventCamera: 0); + var second = WithEventCamera(secondFiles.Clip, eventCamera: 0); + var front = new FakeCameraPlayer(); + var controller = BuildFourCameraController(front); + controller.LoadClips([first, second]); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + vm.Library.SelectedClip = first; + RunPinnedToTestThread(controller.WhenIdleAsync); + vm.Cameras.SelectCameraViewCommand.Execute(CameraViewsViewModel.GridCameraView); + + RunPinnedToTestThread(() => vm.Playback.NextCommand.ExecuteAsync(null)); + RunPinnedToTestThread(controller.WhenIdleAsync); + + controller.CurrentClip.ShouldBe(second); + vm.Cameras.SelectedCameraView.ShouldBe(CameraViewsViewModel.GridCameraView); + } + [Fact] public void StopCommand_WhileASelectionIsWaitingToLoad_KeepsItFromPlaying() { diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 5bdca20..9bb14ad 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -64,6 +64,13 @@ private static CamClip ClipWithCamerasAndEventCamera(int eventCamera, params str return new CamClip(@"C:\clips", "Event Camera Clip", start, [new CamChunk(start, files)], camEvent); } + // The same clip folder with event metadata naming the Tesla camera id that triggered the recording. + private static CamClip WithEventCamera(CamClip clip, int eventCamera) + { + var camEvent = new CamEvent { Reason = "user_interaction_honk", Timestamp = clip.Timestamp, Camera = eventCamera }; + return new CamClip(clip.FullPath, clip.Name, clip.Timestamp, clip.Chunks, camEvent); + } + private static readonly string[] SixCameras = [ CameraNames.Front, @@ -301,14 +308,14 @@ public void SwitchingClips_KeepsTheWatchedCamera_WhenTheNewClipHasIt() } [Theory] - [InlineData(0, CameraNames.Front)] + [InlineData(0, null)] // front, and also what a missing or unreadable field reads as -> no preference [InlineData(3, CameraNames.LeftRepeater)] [InlineData(4, CameraNames.RightRepeater)] [InlineData(5, CameraNames.LeftPillar)] [InlineData(6, CameraNames.RightPillar)] [InlineData(7, CameraNames.Back)] - [InlineData(8, CameraNames.Front)] // cabin camera isn't written to USB -> front - [InlineData(99, CameraNames.Front)] // unknown id -> front + [InlineData(8, null)] // cabin camera isn't written to USB -> no preference + [InlineData(99, null)] // unknown id -> no preference public void CameraIdToView_MapsDocumentedEventCameraIds(int cameraId, string expectedView) { var vm = CreateViewModel(); @@ -318,12 +325,12 @@ public void CameraIdToView_MapsDocumentedEventCameraIds(int cameraId, string exp } [Fact] - public void CameraIdToView_FallsBackToFront_WhenTheClipLacksThatCamera() + public void CameraIdToView_IsNoPreference_WhenTheClipLacksThatCamera() { var vm = CreateViewModel(); vm.Library.SelectedClip = ClipWithCameras(CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater); - vm.Cameras.CameraIdToView(5).ShouldBe(CameraNames.Front); + vm.Cameras.CameraIdToView(5).ShouldBeNull(); vm.Cameras.CameraIdToView(7).ShouldBe(CameraNames.Back); } diff --git a/SentryDeck/ViewModels/CameraViewsViewModel.cs b/SentryDeck/ViewModels/CameraViewsViewModel.cs index c18bc48..9e7320e 100644 --- a/SentryDeck/ViewModels/CameraViewsViewModel.cs +++ b/SentryDeck/ViewModels/CameraViewsViewModel.cs @@ -71,9 +71,10 @@ public void ShowCamerasOf(CamClip clip) } } - // Maps a Tesla event.json camera id to the camera view to auto-focus. + // Maps a Tesla event.json camera id to the camera view to auto-focus, or null when the event names no camera worth leaving the user's chosen view for. // Ids follow the community-documented map (0 front, 3/4 repeaters, 5/6 B-pillars, 7 rear; 1/2/8 are the non-recorded fisheye/narrow/cabin). - // Unknown ids and cameras this clip didn't record fall back to the front (primary) angle. + // Id 0 is no preference: every Dashcam save (honk, launcher tap) reports it, and a missing or unreadable field reads as 0 too, so following it would snap the view back to Front on every clip change. + // Unknown ids and cameras this clip didn't record are no preference either. internal string CameraIdToView(int cameraId) { var camera = cameraId switch @@ -83,10 +84,10 @@ internal string CameraIdToView(int cameraId) 5 => CameraNames.LeftPillar, 6 => CameraNames.RightPillar, 7 => CameraNames.Back, - _ => CameraNames.Front, + _ => null, }; - return IsAvailableView(camera) ? camera : CameraNames.Front; + return IsAvailableView(camera) ? camera : null; } [RelayCommand] diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index c080de3..5ba05df 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -502,10 +502,10 @@ private async Task PlaySelectedClipAsync(CamClip clip, PlaybackPlace resumePlace if (cancellationToken.IsCancellationRequested || resumePlace is not null) return; - // Auto-focus the camera that triggered the event (Full metadata mode). - if (clip.Event is not null) + // Auto-focus the camera that triggered the event (Full metadata mode), when the event names one; otherwise the view the user picked carries over. + if (clip.Event is not null && _cameras.CameraIdToView(clip.Event.Camera) is { } eventCameraView) { - _cameras.SelectedCameraView = _cameras.CameraIdToView(clip.Event.Camera); + _cameras.SelectedCameraView = eventCameraView; } } catch (Exception ex) From 828ece42f75fc5d2bf69884ff13ac626217599a4 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:42:16 -0500 Subject: [PATCH 36/73] Only offer camera tiles for cameras the clip can play A clip whose rear (or pillar) camera was missing from its first segment still showed a Rear tile, and picking it gave a black screen for the whole clip, with nothing in the log to say why. Playback lines every camera up from the clip's first segment, so it leaves out a camera that only starts later or whose first file can't be read, but the tiles were built from every camera file in any segment. Once the clip's media is open, the tiles now narrow to the cameras it actually plays, falling back to Front if the watched camera is gone, and the hidden cameras are logged. Next and Previous select the new clip while the previous one's media is still open, so the tiles wait for the new clip's own media, and an event that names a pillar or the rear still opens on that camera. Clips that play every camera they recorded keep their tiles as they are, so opening them doesn't rebuild the strip. Found in v1.1.0 local testing: F59. --- .../MainWindowViewModelTests.Playback.cs | 108 ++++++++++++++++++ SentryDeck.Tests/MainWindowViewModelTests.cs | 35 ++++++ SentryDeck/Playback/VideoPlayerController.cs | 6 + SentryDeck/ViewModels/CameraViewsViewModel.cs | 57 +++++++-- SentryDeck/ViewModels/PlaybackViewModel.cs | 15 ++- 5 files changed, 209 insertions(+), 12 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 3b6a0e1..3d6323f 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -266,6 +266,114 @@ public void NextCommand_ToAClipWhoseEventReportsCameraZero_KeepsTheChosenView() vm.Cameras.SelectedCameraView.ShouldBe(CameraViewsViewModel.GridCameraView); } + [Fact] + public void NextCommand_FromAClipWithoutPillarsToOneWhosePillarTriggeredIt_OpensOnThatPillar() + { + // Next selects the new clip while the player still has the previous clip's media open, and those cameras must not stand in for the new clip's. + using var firstFiles = TestClipFiles.Create( + chunkCount: 1, + cameras: [CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater]); + using var secondFiles = TestClipFiles.Create(chunkCount: 1, cameras: SixCameras); + var second = WithEventCamera(secondFiles.Clip, eventCamera: 5); + var controller = new VideoPlayerController( + SixCameras.ToDictionary(camera => camera, ICameraPlayer (_) => new FakeCameraPlayer()), + CameraNames.Front, + _playlists.CreateBuilder()); + controller.LoadClips([firstFiles.Clip, second]); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + vm.Library.SelectedClip = firstFiles.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.Back); + + RunPinnedToTestThread(() => vm.Playback.NextCommand.ExecuteAsync(null)); + RunPinnedToTestThread(controller.WhenIdleAsync); + + controller.CurrentClip.ShouldBe(second); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.LeftPillar); + } + + [Fact] + public void NextCommand_ToAClipThatCantPlayTheWatchedCamera_DropsItsTileOnceTheClipOpens() + { + using var firstFiles = TestClipFiles.Create(chunkCount: 1); + using var secondFiles = TestClipFiles.Create( + chunkCount: 2, + omitCamerasFromChunkZero: new HashSet { CameraNames.Back }, + cameras: [CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater]); + var controller = BuildFourCameraController(new FakeCameraPlayer()); + controller.LoadClips([firstFiles.Clip, secondFiles.Clip]); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + vm.Library.SelectedClip = firstFiles.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.Back); + + RunPinnedToTestThread(() => vm.Playback.NextCommand.ExecuteAsync(null)); + RunPinnedToTestThread(controller.WhenIdleAsync); + + controller.CurrentClip.ShouldBe(secondFiles.Clip); + vm.Cameras.CameraViewOptions.ShouldNotContain(option => option.ViewId == CameraNames.Back); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Front); + } + + [Fact] + public void OpeningAClip_WithACameraMissingFromItsFirstSegment_DropsThatCamerasTile() + { + // Playback lines every camera up from the clip's first segment, so a camera that only starts later is never played and its tile would stay black. + using var clipFiles = TestClipFiles.Create( + chunkCount: 2, + omitCamerasFromChunkZero: new HashSet { CameraNames.Back }, + cameras: [CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater]); + + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out _, out _); + + vm.Cameras.CameraViewOptions.Select(option => option.ViewId).ShouldBe( + [ + CameraViewsViewModel.GridCameraView, + CameraNames.Front, + CameraNames.LeftRepeater, + CameraNames.RightRepeater, + ]); + } + + [Fact] + public void SelectingTheOpenClipAgain_StillLeavesOutTheCameraItCantPlay() + { + // Selecting a clip offers every camera it recorded; when the player already has it open, no new media opens to narrow that down again. + using var clipFiles = TestClipFiles.Create( + chunkCount: 2, + omitCamerasFromChunkZero: new HashSet { CameraNames.Back }, + cameras: [CameraNames.Front, CameraNames.Back, CameraNames.LeftRepeater, CameraNames.RightRepeater]); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out var controller, out _); + + vm.Library.SelectedClip = null; + vm.Library.SelectedClip = clipFiles.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + + vm.Cameras.CameraViewOptions.ShouldNotContain(option => option.ViewId == CameraNames.Back); + } + + [Fact] + public void OpeningAClip_ThatPlaysEveryCameraItRecorded_DoesNotRebuildTheTiles() + { + // Each rebuild makes the view regenerate the strip and re-parent every video host, so the open must not redo what the selection already showed. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var front = new FakeCameraPlayer(); + var controller = BuildFourCameraController(front); + controller.LoadClips([clipFiles.Clip]); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + var tileRebuilds = 0; + vm.Cameras.PropertyChanged += (_, e) => tileRebuilds += e.PropertyName == nameof(CameraViewsViewModel.CameraViewOptions) ? 1 : 0; + + vm.Library.SelectedClip = clipFiles.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + + controller.IsMediaOpen.ShouldBeTrue(); + tileRebuilds.ShouldBe(1); + } + [Fact] public void StopCommand_WhileASelectionIsWaitingToLoad_KeepsItFromPlaying() { diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 9bb14ad..177dcd5 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -334,6 +334,41 @@ public void CameraIdToView_IsNoPreference_WhenTheClipLacksThatCamera() vm.Cameras.CameraIdToView(7).ShouldBe(CameraNames.Back); } + [Fact] + public void ShowPlayableCamerasOf_DropsTheTilesOfCamerasTheMediaCantPlay_AndLeavesTheWatchedOneForFront() + { + var vm = CreateViewModel(); + var clip = ClipWithCameras(SixCameras); + vm.Library.SelectedClip = clip; + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.Back); + + vm.Cameras.ShowPlayableCamerasOf(clip, [CameraNames.Front, CameraNames.LeftRepeater, CameraNames.RightRepeater]); + + vm.Cameras.CameraViewOptions.Select(option => option.ViewId).ShouldBe( + [ + CameraViewsViewModel.GridCameraView, + CameraNames.Front, + CameraNames.LeftRepeater, + CameraNames.RightRepeater, + ]); + vm.Cameras.SelectedCameraView.ShouldBe(CameraNames.Front); + vm.Cameras.CameraViewOptions.Single(option => option.IsSelected).ViewId.ShouldBe(CameraNames.Front); + } + + [Fact] + public void ShowPlayableCamerasOf_WhenEveryRecordedCameraPlays_KeepsTheTilesItAlreadyShows() + { + // Replacing the tiles makes the view rebuild the strip and re-parent every video host, which flashes the video for nothing. + var vm = CreateViewModel(); + var clip = ClipWithCameras(SixCameras); + vm.Library.SelectedClip = clip; + var tiles = vm.Cameras.CameraViewOptions; + + vm.Cameras.ShowPlayableCamerasOf(clip, SixCameras); + + vm.Cameras.CameraViewOptions.ShouldBeSameAs(tiles); + } + [Fact] public async Task NumberKeys_SelectTilesByStripPosition_IncludingPillars() { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index dbb0361..1d329ae 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -172,6 +172,12 @@ public static VideoPlayerController Create(IReadOnlyList<(string Camera, Flyleaf /// public ClipMediaSource OpenedMediaSource => _session is { IsOpen: true } session ? session.Source : null; + /// + /// The clip belongs to, or null when nothing is open; it changes along with . + /// Next and Previous move at once, and the previous clip's media stays open until the new clip starts opening, so the two can briefly disagree. + /// + public CamClip OpenedClip => _session is { IsOpen: true } session ? session.Clip : null; + public bool CanPlayPause => CurrentClip is not null && !IsLoading; public bool CanGoNext => Playlist.HasNext; diff --git a/SentryDeck/ViewModels/CameraViewsViewModel.cs b/SentryDeck/ViewModels/CameraViewsViewModel.cs index 9e7320e..cfdfc07 100644 --- a/SentryDeck/ViewModels/CameraViewsViewModel.cs +++ b/SentryDeck/ViewModels/CameraViewsViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Serilog; namespace SentryDeck; @@ -48,7 +49,7 @@ public CameraViewsViewModel() private string _selectedCameraView = CameraNames.Front; /// - /// The selectable views for the current clip: the grid plus one tile per camera the clip actually recorded, in order. + /// The selectable views for the current clip: the grid plus one tile per camera the clip actually recorded (and, once its media is open, can play), in order. /// [ObservableProperty] private IReadOnlyList _cameraViewOptions = BuildCameraViewOptions(null); @@ -60,15 +61,33 @@ public CameraViewsViewModel() public void ShowCamerasOf(CamClip clip) { CameraViewOptions = BuildCameraViewOptions(clip); - if (IsAvailableView(SelectedCameraView)) - { - // Same view id as before, but the option objects are new, so re-mark the selected one. - SyncCameraViewSelection(); - } - else + KeepSelectedViewIfOffered(); + } + + /// + /// Narrows the camera tiles of to the cameras its opened media can actually play. + /// A recorded camera can still be left out of playback (missing from the clip's first segment, or its first file unreadable), and its tile would only ever show black. + /// + public void ShowPlayableCamerasOf(CamClip clip, IEnumerable playableCameras) + { + var playable = playableCameras.ToHashSet(); + var options = BuildCameraViewOptions(clip, playable); + + // Most clips play every camera they recorded; replacing identical tiles would make the view rebuild the strip and re-parent every video host for nothing. + if (options.Select(option => option.ViewId).SequenceEqual(CameraViewOptions.Select(option => option.ViewId))) + return; + + var unplayable = RecordedCameras(clip).Where(camera => !playable.Contains(camera)).ToArray(); + if (unplayable.Length > 0) { - SelectedCameraView = CameraNames.Front; + Log.Warning( + "Hiding the tiles of cameras the clip recorded but can't play. ClipName={ClipName}; Cameras={Cameras}", + clip.Name, + unplayable); } + + CameraViewOptions = options; + KeepSelectedViewIfOffered(); } // Maps a Tesla event.json camera id to the camera view to auto-focus, or null when the event names no camera worth leaving the user's chosen view for. @@ -112,12 +131,15 @@ private void SelectCameraView(string cameraView) _ => CameraNames.DisplayName(camera), }; - private static IReadOnlyList BuildCameraViewOptions(CamClip clip) + // Only recognized cameras get a tile: each needs a dedicated Flyleaf host wired in the view, so an unknown future suffix is ingested and played in the engine but not shown. + private static IEnumerable RecordedCameras(CamClip clip) => + CameraNames.All.Where(camera => clip.Chunks.Any(chunk => chunk.Files.ContainsKey(camera))); + + private static IReadOnlyList BuildCameraViewOptions(CamClip clip, IReadOnlySet playableCameras = null) { - // Only recognized cameras get a tile: each needs a dedicated Flyleaf host wired in the view, so an unknown future suffix is ingested and played in the engine but not shown. var cameras = clip is null ? DefaultCameras - : CameraNames.All.Where(camera => clip.Chunks.Any(chunk => chunk.Files.ContainsKey(camera))).ToArray(); + : RecordedCameras(clip).Where(camera => playableCameras is null || playableCameras.Contains(camera)).ToArray(); // Metadata-only clips (no camera files at all) keep the classic strip rather than none. if (cameras.Length == 0) @@ -136,6 +158,19 @@ private static IReadOnlyList BuildCameraViewOptions(CamClip cl private bool IsAvailableView(string view) => view is not null && CameraViewOptions.Any(option => option.ViewId == view); + private void KeepSelectedViewIfOffered() + { + if (IsAvailableView(SelectedCameraView)) + { + // Same view id as before, but the option objects are new, so re-mark the selected one. + SyncCameraViewSelection(); + } + else + { + SelectedCameraView = CameraNames.Front; + } + } + partial void OnSelectedCameraViewChanged(string value) => SyncCameraViewSelection(); private void SyncCameraViewSelection() diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index 5ba05df..3a47d0a 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -41,7 +41,7 @@ public sealed partial class PlaybackViewModel : ObservableObject /// Yields to the UI before a clip loads so the window stays responsive. /// Runs an action on the UI thread. /// Where playback failures are reported. - /// Refocused on the camera that triggered an event clip once it opens. + /// Refocused on the camera that triggered an event clip once it opens, and narrowed to the cameras the opened clip can play. public PlaybackViewModel( Func playerControllerFactory, Func backgroundYield, @@ -226,6 +226,7 @@ public void Select(CamClip clip) RecomputeSelectedClipTimeline(); NotifyMarkersChanged(); + ShowPlayableCameras(); _selectionCts?.Cancel(); _selectionCts?.Dispose(); @@ -643,6 +644,17 @@ private void RecomputeFromEstimatedTimeline(CamClip clip) _eventPosition = fraction is >= 0 and <= 1 ? fraction : null; } + // Selecting a clip offers a tile for every camera it recorded, but playback can leave a recorded camera out, and its tile would then stay black. + // Only the selected clip's opened media knows which cameras it plays; a clip selected again while it is still open gets no new media, so this also runs on selection. + // Next and Previous select the new clip while the previous clip's media is still open, and narrowing to its cameras would hide the new clip's own tiles and with them the camera its event names. + private void ShowPlayableCameras() + { + if (SelectedClip is { } clip && _playerController?.OpenedClip == clip && _playerController.OpenedMediaSource is { } mediaSource) + { + _cameras.ShowPlayableCamerasOf(clip, mediaSource.CameraPlaylistPaths.Keys); + } + } + private void NotifyMarkersChanged() { OnPropertyChanged(nameof(EventMarkerPosition)); @@ -730,6 +742,7 @@ private void HandlePlayerControllerPropertyChanged(string propertyName) OnPropertyChanged(nameof(OpenedMediaSource)); RecomputeSelectedClipTimeline(); NotifyMarkersChanged(); + ShowPlayableCameras(); break; case nameof(VideoPlayerController.ErrorMessage): From 55d5aa9beceedd7f923d5df22cf46602fa05fac4 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:01:58 -0500 Subject: [PATCH 37/73] Keep the Download FFmpeg prompt until FFmpeg is installed Without FFmpeg, picking a folder or pressing Rescan (the obvious first things to do) replaced the "FFmpeg Required" prompt for good, and so did dismissing a failed download. Clicking a clip then showed black video with Play enabled and doing nothing, and the only way back to Download FFmpeg was a restart. A failed download also showed raw socket text such as "actively refused it. (127.0.0.1:9)", and closing the app without FFmpeg logged six NullReferenceExceptions. The prompt lived only as a passing notice in the shared overlay, so any scan or Dismiss cleared it, and nothing ever showed it again. It is now a standing notice: other notices still cover it but keep the Download FFmpeg button, and clearing them brings the prompt back until a download starts or FFmpeg is installed. Without a player the video area stays covered and Play stays disabled, and a running download shows the loading overlay even when a clip is selected. Download failures explain what to check (connection, proxy or firewall, a writable folder) and where to put a manually downloaded FFmpeg. The camera hosts now join the window only when the player is created, because a loaded host logs through Flyleaf's engine on close, and that engine never starts without FFmpeg. Found in v1.1.0 local testing: F26, F32, F34. --- .../MainWindowViewModelTests.Clips.cs | 9 +- .../MainWindowViewModelTests.FFmpeg.cs | 124 ++++++++++++++++++ SentryDeck.Tests/MainWindowViewModelTests.cs | 14 +- SentryDeck.Tests/PackageManagerTests.cs | 47 +++++++ SentryDeck/MainWindow.xaml.cs | 19 ++- SentryDeck/MainWindowViewModel.cs | 78 ++++++----- SentryDeck/Services/PackageManager.cs | 35 +++++ .../ViewModels/ErrorOverlayViewModel.cs | 28 +++- SentryDeck/ViewModels/PlaybackViewModel.cs | 7 +- 9 files changed, 311 insertions(+), 50 deletions(-) create mode 100644 SentryDeck.Tests/MainWindowViewModelTests.FFmpeg.cs diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs index 4eea117..4fab62c 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Clips.cs @@ -317,10 +317,11 @@ public async Task TypingInSearch_DoesNotRebindTheListPerKeystroke() [Fact] public async Task Search_HidesTheOpenClip_KeepsItOpenAndInView() { - var clips = TestClips.Create(3); - var vm = await LoadedViewModelAsync(clips); - var open = clips[1]; - vm.Library.ListSelection = open; + // The video only shows with a player, so the clip is opened in a real one rather than just selected. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var (vm, controller, _) = CreateRescannableViewModel(_ => [clipFiles.Clip]); + await OpenClipAsync(vm, controller, clipFiles.Clip); + var open = vm.Library.SelectedClip; vm.Library.FilterText = "zzzz"; vm.Library.ApplyFilter(); diff --git a/SentryDeck.Tests/MainWindowViewModelTests.FFmpeg.cs b/SentryDeck.Tests/MainWindowViewModelTests.FFmpeg.cs new file mode 100644 index 0000000..93dc1bc --- /dev/null +++ b/SentryDeck.Tests/MainWindowViewModelTests.FFmpeg.cs @@ -0,0 +1,124 @@ +using System.Net.Http; +using System.Net.Sockets; + +namespace SentryDeck.Tests; + +public sealed partial class MainWindowViewModelTests +{ + // --- First run without FFmpeg: nothing plays until it is downloaded, so the prompt that downloads it must survive everything else the user does. --- + + private const string ConnectionRefused = "No connection could be made because the target machine actively refused it. (127.0.0.1:9)"; + + // Playback has failed to start for want of FFmpeg, as on a fresh install, and the download (if the test runs it) fails the way an offline PC does. + private static async Task ViewModelWithoutFFmpegAsync( + Func> clipLoader = null, + Func downloadFFmpeg = null) + { + var vm = new MainWindowViewModel( + () => null!, + clipLoader: clipLoader, + tryStartFlyleaf: () => false, + downloadFFmpeg: downloadFFmpeg ?? (() => Task.FromException(new HttpRequestException(ConnectionRefused, new SocketException(10061))))); + await vm.StartPlaybackAsync(); + return vm; + } + + private static void ShouldOfferTheFFmpegDownload(MainWindowViewModel vm) + { + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("FFmpeg Required"); + vm.Error.ShowFFmpegDownloadButton.ShouldBeTrue(); + vm.Error.CanDismiss.ShouldBeFalse(); + } + + [Fact] + public async Task LoadClips_WhileFFmpegIsMissing_KeepsTheDownloadPrompt() + { + // Picking a folder or rescanning is the obvious first thing to do in a footage viewer, and it used to wipe the only Download FFmpeg button until a restart. + var vm = await ViewModelWithoutFFmpegAsync(clipLoader: _ => TestClips.Create(2)); + + await vm.Library.LoadClipsAsync(["root"]); + + vm.Library.ClipCount.ShouldBe(2); + ShouldOfferTheFFmpegDownload(vm); + vm.HasNoClipSelected.ShouldBeFalse(); + } + + [Fact] + public async Task LoadClips_WithNoRootsWhileFFmpegIsMissing_StillOffersTheDownload() + { + var vm = await ViewModelWithoutFFmpegAsync(); + + await vm.Library.LoadClipsAsync([]); + + vm.Error.Title.ShouldBe("No dashcam footage yet"); + vm.Error.ShowFFmpegDownloadButton.ShouldBeTrue(); + + vm.Error.DismissCommand.Execute(null); + + ShouldOfferTheFFmpegDownload(vm); + } + + [Fact] + public async Task SelectingAClip_WhileFFmpegIsMissing_KeepsTheVideoHiddenAndPlayDisabled() + { + // With no player, the hosts would only show black video behind a Play button that does nothing. + var vm = await ViewModelWithoutFFmpegAsync(clipLoader: _ => TestClips.Create(2)); + await vm.Library.LoadClipsAsync(["root"]); + + vm.Library.SelectedClip = vm.Library.FilteredClips[0]; + + vm.ShowVideoHosts.ShouldBeFalse(); + vm.Playback.CanPlayPause.ShouldBeFalse(); + ShouldOfferTheFFmpegDownload(vm); + } + + [Fact] + public async Task DownloadFFmpeg_WhenTheConnectionFails_ExplainsWhatToTryAndKeepsTheRetry() + { + var vm = await ViewModelWithoutFFmpegAsync(); + + await vm.DownloadFFmpegCommand.ExecuteAsync(null); + + vm.Error.IsVisible.ShouldBeTrue(); + vm.Error.Title.ShouldBe("Download Failed"); + vm.Error.Details.ShouldContain("proxy or firewall"); + vm.Error.Details.ShouldNotContain("127.0.0.1"); + vm.Error.ShowFFmpegDownloadButton.ShouldBeTrue(); + vm.IsLoading.ShouldBeFalse(); + } + + [Fact] + public async Task DismissingADownloadFailure_ReturnsToTheFFmpegPrompt() + { + // Dismiss used to clear the retry along with the failure, leaving no way to download FFmpeg short of a restart. + var vm = await ViewModelWithoutFFmpegAsync(); + await vm.DownloadFFmpegCommand.ExecuteAsync(null); + + vm.Error.DismissCommand.Execute(null); + + ShouldOfferTheFFmpegDownload(vm); + } + + [Fact] + public async Task DownloadFFmpeg_WhileItRuns_ShowsTheLoadingOverlayInsteadOfThePromptOrBlackVideo() + { + var download = new TaskCompletionSource(); + var vm = await ViewModelWithoutFFmpegAsync(clipLoader: _ => TestClips.Create(1), downloadFFmpeg: () => download.Task); + await vm.Library.LoadClipsAsync(["root"]); + vm.Library.SelectedClip = vm.Library.FilteredClips[0]; + + var running = vm.DownloadFFmpegCommand.ExecuteAsync(null); + + vm.Error.IsVisible.ShouldBeFalse(); + vm.IsLoading.ShouldBeTrue(); + vm.ShowStatusOverlay.ShouldBeTrue(); + vm.ShowVideoHosts.ShouldBeFalse(); + + download.SetException(new HttpRequestException(ConnectionRefused)); + await running; + + vm.Error.Title.ShouldBe("Download Failed"); + vm.Error.ShowFFmpegDownloadButton.ShouldBeTrue(); + } +} diff --git a/SentryDeck.Tests/MainWindowViewModelTests.cs b/SentryDeck.Tests/MainWindowViewModelTests.cs index 177dcd5..d3a08a9 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.cs @@ -448,9 +448,9 @@ public void Error_ShowsStatusOverlay_AndReportsError() [Fact] public void SelectingClip_HidesOverlay_AndShowsVideo() { - var vm = CreateViewModel(); + using var clipFiles = TestClipFiles.Create(chunkCount: 1); - vm.Library.SelectedClip = TestClips.Create(1)[0]; + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out _, out _); vm.HasNoClipSelected.ShouldBeFalse(); vm.ShowStatusOverlay.ShouldBeFalse(); @@ -460,10 +460,10 @@ public void SelectingClip_HidesOverlay_AndShowsVideo() [Fact] public void CanPlayPause_RequiresClipOrPlayback_AndNotLoading() { - var vm = CreateViewModel(); - vm.Playback.CanPlayPause.ShouldBeFalse(); + CreateViewModelWithController(out _, out _).Playback.CanPlayPause.ShouldBeFalse(); - vm.Library.SelectedClip = TestClips.Create(1)[0]; + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out _, out _); vm.Playback.CanPlayPause.ShouldBeTrue(); vm.Playback.IsLoading = true; @@ -472,7 +472,7 @@ public void CanPlayPause_RequiresClipOrPlayback_AndNotLoading() // Even with no selected clip, an in-flight playback keeps the toggle live. vm.Playback.IsLoading = false; vm.Library.SelectedClip = null; - vm.Playback.IsPlaying = true; + vm.Playback.IsPlaying.ShouldBeTrue(); vm.Playback.CanPlayPause.ShouldBeTrue(); } @@ -584,7 +584,6 @@ public void DismissError_ClearsErrorState() { var vm = CreateViewModel(); vm.Error.IsVisible = true; - vm.Error.ShowFFmpegDownloadButton = true; vm.Error.CanDismiss = false; vm.Error.Title = "Boom"; vm.Error.Details = "Something went wrong"; @@ -592,7 +591,6 @@ public void DismissError_ClearsErrorState() 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.Tests/PackageManagerTests.cs b/SentryDeck.Tests/PackageManagerTests.cs index 4eb1a36..493df98 100644 --- a/SentryDeck.Tests/PackageManagerTests.cs +++ b/SentryDeck.Tests/PackageManagerTests.cs @@ -1,5 +1,7 @@ using System.IO; using System.IO.Compression; +using System.Net.Http; +using System.Net.Sockets; namespace SentryDeck.Tests; @@ -118,6 +120,51 @@ public void RemoveSupersededFFmpegDirectories_LeavesEverythingElseAlone() Directory.Exists(Path.Combine(installRoot, "runtimes")).ShouldBeTrue(); } + [Theory] + [InlineData("refused")] + [InlineData("dropped mid-download")] + [InlineData("timed out")] + public void DescribeDownloadFailure_NetworkError_SaysWhatToCheckInsteadOfTheSocketError(string failure) + { + Exception exception = failure switch + { + "refused" => new HttpRequestException("No connection could be made because the target machine actively refused it. (127.0.0.1:9)", new SocketException(10061)), + "dropped mid-download" => new IOException("Unable to read data from the transport connection.", new SocketException(10054)), + _ => new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout of 1800 seconds elapsing.", new TimeoutException()), + }; + + var description = PackageManager.DescribeDownloadFailure(exception); + + description.ShouldContain("online"); + description.ShouldContain("proxy or firewall"); + description.ShouldNotContain(exception.Message); + } + + [Fact] + public void DescribeDownloadFailure_FolderNotWritable_SuggestsMovingTheApp() + { + var description = PackageManager.DescribeDownloadFailure(new UnauthorizedAccessException("Access to the path is denied.")); + + description.ShouldContain("folder you can write to"); + } + + [Fact] + public void DescribeDownloadFailure_UnexpectedError_KeepsItsMessageAsTheOnlyClue() + { + var description = PackageManager.DescribeDownloadFailure(new InvalidDataException("End of Central Directory record could not be found.")); + + description.ShouldContain("End of Central Directory record could not be found."); + } + + [Fact] + public void DescribeDownloadFailure_EndsWithHowToInstallFFmpegByHand() + { + // Someone behind a firewall that blocks GitHub can never download it from the app, so the message has to say where a manually downloaded build goes. + var description = PackageManager.DescribeDownloadFailure(new HttpRequestException("Connection refused")); + + description.ShouldContain(Path.Combine(AppContext.BaseDirectory, "ffmpeg-9.0-bin")); + } + [Fact] public void ExtractFFmpegBin_ClearsAnExistingDestination() { diff --git a/SentryDeck/MainWindow.xaml.cs b/SentryDeck/MainWindow.xaml.cs index a120af9..2df5210 100644 --- a/SentryDeck/MainWindow.xaml.cs +++ b/SentryDeck/MainWindow.xaml.cs @@ -26,6 +26,7 @@ public partial class MainWindow : Window private bool _isClosing; private bool _isReadyToClose; + private bool _areCameraHostsInWindow; public MainWindow() { @@ -41,8 +42,11 @@ public MainWindow() [CameraNames.RightPillar] = RightPillarFlyleafHost, }; - _viewModel = new MainWindowViewModel( - () => VideoPlayerController.Create([.. _cameraHosts.Select(pair => (pair.Key, pair.Value))])); + // A host that has loaded logs through Flyleaf's engine when the window closes, and that engine only starts once FFmpeg is installed, so without FFmpeg every host threw on close. + // The hosts therefore join the window only when the player that drives them is created (see CreatePlayerController). + FlyleafHostPool.Children.Clear(); + + _viewModel = new MainWindowViewModel(CreatePlayerController); _viewModel.SearchBoxFocusRequested += OnSearchBoxFocusRequested; _viewModel.Cameras.PropertyChanged += CamerasOnPropertyChanged; @@ -58,6 +62,15 @@ public MainWindow() UpdateCameraHostLayout(); } + // Only called once Flyleaf has started. + private VideoPlayerController CreatePlayerController() + { + _areCameraHostsInWindow = true; + UpdateCameraHostLayout(); + + return VideoPlayerController.Create([.. _cameraHosts.Select(pair => (pair.Key, pair.Value))]); + } + private async void Window_ContentRendered(object sender, EventArgs e) { await _viewModel.InitializeAsync(); @@ -230,7 +243,7 @@ private void CameraTileSlot_Loaded(object sender, RoutedEventArgs e) private void UpdateCameraHostLayout() { - if (PrimaryCameraHostSlot is null) + if (PrimaryCameraHostSlot is null || !_areCameraHostsInWindow) return; var placed = new HashSet(); diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index 3891d92..19c7734 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -15,6 +15,8 @@ namespace SentryDeck; public partial class MainWindowViewModel : ObservableObject { private readonly FlyleafRuntime _flyleafRuntime = new(); + private readonly Func _tryStartFlyleaf; + private readonly Func _downloadFFmpeg; private readonly Dispatcher _dispatcher; private bool _isInitialized; private bool _isDownloadingFFmpeg; @@ -32,6 +34,10 @@ public partial class MainWindowViewModel : ObservableObject /// Overridable for tests. /// Runs an action on the UI thread. /// Defaults to the dispatcher hop; overridable for tests, which have no pumped message loop to service it. + /// Starts Flyleaf, returning false when FFmpeg is missing. + /// Defaults to the real runtime; overridable for tests. + /// Downloads and installs FFmpeg. + /// Defaults to ; overridable for tests. public MainWindowViewModel( Func playerControllerFactory, Func> clipLoader = null, @@ -39,9 +45,13 @@ public MainWindowViewModel( IClipExporter clipExporter = null, Func savePathPicker = null, IClipMediaSourceBuilder exportMediaSourceBuilder = null, - Action uiInvoker = null) + Action uiInvoker = null, + Func tryStartFlyleaf = null, + Func downloadFFmpeg = null) { _dispatcher = Dispatcher.CurrentDispatcher; + _tryStartFlyleaf = tryStartFlyleaf ?? _flyleafRuntime.TryStart; + _downloadFFmpeg = downloadFFmpeg ?? PackageManager.DownloadAndExtractFFmpeg; Playback = new PlaybackViewModel( playerControllerFactory, @@ -125,14 +135,17 @@ public MainWindowViewModel( // The full-screen overlay only covers the no-video states (scanning with no clip, error, empty); as a WPF sibling it can't draw over the Flyleaf video surface anyway. // While a selected clip loads, the hosts stay visible and simply show black until the first frame decodes, with no loading screen flashing mid-playback. - public bool ShowStatusOverlay => (IsLoading && Library.SelectedClip is null) || Error.IsVisible || HasNoClipSelected; + public bool ShowStatusOverlay => (IsLoading && !HasVideo) || Error.IsVisible || HasNoClipSelected; - public bool ShowVideoHosts => Library.SelectedClip is not null && !Error.IsVisible; + public bool ShowVideoHosts => HasVideo && !Error.IsVisible; public bool HasError => Error.IsVisible; public bool HasNoClipSelected => Library.SelectedClip is null && !IsLoading && !Error.IsVisible; + // Without FFmpeg there is no player, and a selected clip would only show black hosts behind an enabled Play button that does nothing. + private bool HasVideo => Library.SelectedClip is not null && Playback.HasPlayer; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowMainContent))] private bool _showAboutPage; @@ -151,15 +164,7 @@ public async Task InitializeAsync() _ = About.CheckForUpdatesAsync(); #endif - if (_flyleafRuntime.TryStart()) - { - InitializePlayer(); - await Library.ReloadAsync(); - } - else - { - ShowFFmpegMissingError(); - } + await StartPlaybackAsync(); } public void Shutdown() @@ -224,27 +229,23 @@ private void ToggleAbout() private async Task DownloadFFmpegAsync() { SetDownloadingFFmpeg(true); - Error.Clear(); + + // The loading overlay takes the prompt's place while the download runs. + Error.WithdrawFFmpegPrompt(); try { Log.Debug("Starting FFmpeg download workflow"); - await PackageManager.DownloadAndExtractFFmpeg(); - if (_flyleafRuntime.TryStart()) - { - InitializePlayer(); - await Library.ReloadAsync(); - } - else - { - ShowFFmpegMissingError(); - } + await _downloadFFmpeg(); + await StartPlaybackAsync(); } catch (Exception ex) { Log.Error(ex, "Failed to download FFmpeg"); - Error.Show("Download Failed", $"Failed to download FFmpeg: {ex.Message}"); - Error.ShowFFmpegDownloadButton = true; + + // The failure covers the prompt rather than replacing it, so it keeps the retry, and dismissing it returns to the prompt. + Error.ShowFFmpegPrompt(); + Error.Show("Download Failed", PackageManager.DescribeDownloadFailure(ex)); } finally { @@ -252,17 +253,28 @@ private async Task DownloadFFmpegAsync() } } - private void SetDownloadingFFmpeg(bool value) + /// + /// Starts the player and scans for clips, or asks for FFmpeg when it is missing. + /// Shared by startup and the end of a download, so FFmpeg turning out to be unusable either way leaves the same standing prompt. + /// Internal so tests can reach the FFmpeg-missing state without the startup update check going to the network. + /// + internal async Task StartPlaybackAsync() { - _isDownloadingFFmpeg = value; - NotifyLoadingChanged(); + if (!_tryStartFlyleaf()) + { + Log.Debug("Showing FFmpeg missing prompt"); + Error.ShowFFmpegPrompt(); + return; + } + + InitializePlayer(); + await Library.ReloadAsync(); } - private void ShowFFmpegMissingError() + private void SetDownloadingFFmpeg(bool value) { - Log.Debug("Showing FFmpeg missing prompt"); - Error.ShowFFmpegDownloadButton = true; - Error.Show("FFmpeg Required", "FFmpeg is required to play clips. This will download about 80MB.", canDismiss: false); + _isDownloadingFFmpeg = value; + NotifyLoadingChanged(); } private static async Task RunKeyActionAsync(Func action, Key key) @@ -399,7 +411,7 @@ private void OnLibraryPropertyChanged(object sender, PropertyChangedEventArgs e) private void OnPlaybackPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(PlaybackViewModel.IsLoading)) + if (e.PropertyName is nameof(PlaybackViewModel.IsLoading) or nameof(PlaybackViewModel.HasPlayer)) { NotifyLoadingChanged(); } diff --git a/SentryDeck/Services/PackageManager.cs b/SentryDeck/Services/PackageManager.cs index 09eb324..dce88aa 100644 --- a/SentryDeck/Services/PackageManager.cs +++ b/SentryDeck/Services/PackageManager.cs @@ -1,6 +1,7 @@ using System.IO; using System.IO.Compression; using System.Net.Http; +using System.Net.Sockets; using System.Runtime.InteropServices; using Serilog; @@ -169,6 +170,40 @@ internal static int RemoveSupersededFFmpegDirectories(string installRoot, string return removedCount; } + /// + /// Explains a failed FFmpeg download in words the user can act on, ending with how to install FFmpeg by hand. + /// The exception's own text names sockets and IP addresses, which tells nobody what to try next; the caller logs the exception itself for diagnosis. + /// + internal static string DescribeDownloadFailure(Exception exception) + { + var cause = exception switch + { + _ when IsNetworkFailure(exception) => + "Sentry Deck couldn't download FFmpeg. Check that this PC is online and that no proxy or firewall blocks github.com, then try again.", + UnauthorizedAccessException => + "Sentry Deck couldn't save FFmpeg in its own folder. Move Sentry Deck to a folder you can write to, then try again.", + IOException => + "Sentry Deck couldn't save FFmpeg. Check that the drive has free space, then try again.", + _ => $"Sentry Deck couldn't install FFmpeg ({exception.Message}). Try again.", + }; + + return $"{cause}\n\nTo install it yourself, copy the contents of the bin folder of an FFmpeg {FFmpegReleaseBranch} shared build into {Path.Combine(FFmpegInstallRoot, FFmpegBinFolderName)} and restart Sentry Deck."; + } + + // A dropped connection can surface while the body streams to disk, as an IOException wrapping the socket error, so the whole chain is searched rather than only the outer type. + private static bool IsNetworkFailure(Exception exception) + { + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is HttpRequestException or HttpIOException or SocketException or TimeoutException or TaskCanceledException) + { + return true; + } + } + + return false; + } + /// /// Returns the installed FFmpeg bin directory, or null when it is missing. /// diff --git a/SentryDeck/ViewModels/ErrorOverlayViewModel.cs b/SentryDeck/ViewModels/ErrorOverlayViewModel.cs index 1157f04..6e10034 100644 --- a/SentryDeck/ViewModels/ErrorOverlayViewModel.cs +++ b/SentryDeck/ViewModels/ErrorOverlayViewModel.cs @@ -6,6 +6,7 @@ namespace SentryDeck; /// /// The notice that covers the video area: a genuine error, or the friendly prompt shown when there is no footage yet. /// Every feature reports through the one instance the main window owns, so a newer notice replaces the one on screen. +/// The FFmpeg prompt is the exception: it stands until it is withdrawn, so clearing a notice returns to it. /// public sealed partial class ErrorOverlayViewModel : ObservableObject { @@ -25,6 +26,8 @@ public sealed partial class ErrorOverlayViewModel : ObservableObject [ObservableProperty] private bool _isEmptyState; + // True from the FFmpeg prompt until the prompt is withdrawn. + // Nothing can play until FFmpeg is installed, so the offer outlives the notices that cover the prompt: they keep the Download FFmpeg button, and clearing one (a scan starting, or Dismiss) brings the prompt back, instead of leaving no way to install FFmpeg short of a restart. [ObservableProperty] private bool _showFFmpegDownloadButton; @@ -39,14 +42,37 @@ public void Show(string title, string details, bool canDismiss = true, bool isEm public void Clear() { + if (ShowFFmpegDownloadButton) + { + ShowFFmpegPrompt(); + return; + } + IsVisible = false; - ShowFFmpegDownloadButton = false; CanDismiss = true; IsEmptyState = false; Title = null; Details = null; } + /// + /// Asks the user to download FFmpeg, and keeps offering it under any later notice until . + /// + public void ShowFFmpegPrompt() + { + ShowFFmpegDownloadButton = true; + Show("FFmpeg Required", "FFmpeg is required to play clips. This will download about 80MB.", canDismiss: false); + } + + /// + /// Stops offering the FFmpeg download, because a download is under way or FFmpeg is installed, and clears the prompt. + /// + public void WithdrawFFmpegPrompt() + { + ShowFFmpegDownloadButton = false; + Clear(); + } + [RelayCommand] private void Dismiss() => Clear(); } diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index 3a47d0a..d0958dd 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -112,7 +112,8 @@ public PlaybackViewModel( public bool CanSeek => _playerController?.IsMediaOpen == true && !IsLoading && _playerController.Duration > TimeSpan.Zero; - public bool CanPlayPause => (SelectedClip is not null || IsPlaying) && !IsLoading; + // Without a player (FFmpeg missing) Play would do nothing at all, so it must not look available. + public bool CanPlayPause => HasPlayer && (SelectedClip is not null || IsPlaying) && !IsLoading; // A paused or finished clip still holds its files open, so Stop must stay available to let go of them without resuming first. public bool CanStop => IsPlaying || IsLoading || _playerController?.IsMediaOpen == true; @@ -196,6 +197,10 @@ public void InitializePlayer() _playerController = _playerControllerFactory(); _playerController.PropertyChanged += PlayerControllerOnPropertyChanged; _playerController.PlaybackSpeed = PlaybackSpeed; + + // The player can arrive mid-session, once a first-run FFmpeg download finishes. + OnPropertyChanged(nameof(HasPlayer)); + OnPropertyChanged(nameof(CanPlayPause)); } public void Shutdown() From 7ea2fba0dc01e2f878ea12c41a8138b5d724117d Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:10:07 -0500 Subject: [PATCH 38/73] Keep keyboard focus on Previous frame, Next frame and Event after pressing them Clicking Previous frame or Event, or pressing Enter on them after tabbing there, made the focus ring vanish: the next Enter did nothing, and Tab started over from the search box, where a following Space typed into the box instead of pausing. Next frame did the same now and then, and 30 Enter presses on it stepped only about 8 frames. Their commands reported themselves unavailable while they ran, so WPF disabled each button for the length of its seek, and a disabled button gives up keyboard focus and never gets it back. The buttons now stay enabled while their command runs, as Faster and Slower already do. Repeated presses queue up in order, the same way the , . and E keys already behave. Found in v1.1.0 local testing: RI-1. --- .../MainWindowViewModelTests.Playback.cs | 27 +++++++++++++++++++ SentryDeck/ViewModels/PlaybackViewModel.cs | 8 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index 3d6323f..a2a9994 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -507,6 +507,33 @@ public void PlayAfterStop_PutsTheNowPlayingBadgeBack() vm.Playback.NowPlayingClip.ShouldBe(clipFiles.Clip); } + [Fact] + public void FrameStepAndEventButtons_WhileTheirCommandRuns_StayEnabled() + { + // WPF disables a button while its command can't execute, and the disabled button loses keyboard focus for good, so pressing Enter on Previous frame left focus nowhere. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(30)); + var vm = CreateViewModelPlayingClip(clip, out var controller, out var front); + RunPinnedToTestThread(() => vm.Playback.PlayPauseCommand.ExecuteAsync(null)); + + // A resume held at the gate keeps the player busy, so every command below is still running when its button checks it. + front.PlayGate = new TaskCompletionSource(); + var resume = controller.PlayAsync(); + IAsyncRelayCommand[] commands = + [ + vm.Playback.StepFrameBackwardCommand, + vm.Playback.StepFrameForwardCommand, + vm.Playback.JumpToEventCommand, + ]; + var running = commands.Select(command => command.ExecuteAsync(null)).ToList(); + + running.ShouldAllBe(task => !task.IsCompleted); + commands.ShouldAllBe(command => command.CanExecute(null)); + + front.PlayGate.SetResult(); + RunPinnedToTestThread(() => Task.WhenAll(running.Append(resume))); + } + // Selects the clip through the list and waits until the player has it open and playing. // The view-model's handlers run inline on whichever thread the controller raises them, because the open finishes on a thread-pool continuation. private MainWindowViewModel CreateViewModelPlayingClip(CamClip clip, out VideoPlayerController controller, out FakeCameraPlayer front) diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index d0958dd..fd94f71 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -411,13 +411,15 @@ private async Task StopAsync() Stopped?.Invoke(this, EventArgs.Empty); } - [RelayCommand(CanExecute = nameof(CanSeek))] + // These buttons stay enabled while their command runs: WPF disables a button whose command can't execute, and a disabled button drops keyboard focus and never gets it back, so a keyboard user's next Enter or Tab would go nowhere. + // Overlapping runs are safe because the controller queues every step and seek in order, just as it does for the , . and E keys. + [RelayCommand(CanExecute = nameof(CanSeek), AllowConcurrentExecutions = true)] private Task StepFrameBackwardAsync() => StepFrameAsync(forward: false); - [RelayCommand(CanExecute = nameof(CanSeek))] + [RelayCommand(CanExecute = nameof(CanSeek), AllowConcurrentExecutions = true)] private Task StepFrameForwardAsync() => StepFrameAsync(forward: true); - [RelayCommand(CanExecute = nameof(CanJumpToEvent))] + [RelayCommand(CanExecute = nameof(CanJumpToEvent), AllowConcurrentExecutions = true)] private async Task JumpToEventAsync() { if (!CanJumpToEvent) From 38bde6f19af17cc72db856dd6c2fd13189f20f26 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:14:24 -0500 Subject: [PATCH 39/73] Start the next clip where it opens when the seek thumb was held across the change Holding the seek thumb and pressing Ctrl+Left or Ctrl+Right opened the other clip, but releasing the mouse then jumped that clip to the spot the old clip's thumb was at, so a clip that opens 10 seconds before its event skipped the lead-in. While the mouse was still down, the time readout also showed the old thumb position against the new clip's length instead of where the new clip was playing. The seek gesture never noticed the clip change: the release seeked whatever clip was open by then to the thumb's fraction, and the thumb ignored the new clip's position until the mouse came up. Moving to another clip now ends the gesture, so the release does nothing and the thumb follows the new clip right away. Reloading the playlist while the thumb is held still finishes the seek as before, because the open clip stays paused for the gesture until the release resumes it. Found in v1.1.0 local testing: RI-3. --- .../MainWindowViewModelTests.Playback.cs | 67 +++++++++++++++++++ SentryDeck/ViewModels/PlaybackViewModel.cs | 38 +++++++++++ 2 files changed, 105 insertions(+) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs index a2a9994..5713406 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Playback.cs @@ -534,6 +534,73 @@ public void FrameStepAndEventButtons_WhileTheirCommandRuns_StayEnabled() RunPinnedToTestThread(() => Task.WhenAll(running.Append(resume))); } + [Fact] + public void SeekThumbHeldAcrossAClipChange_ReleasingIt_LeavesTheNewClipAtItsOwnStart() + { + // The thumb's position is a fraction of the clip it was grabbed on, so releasing it after Ctrl+Left jumped the new clip there and skipped its event lead-in. + using var firstFiles = TestClipFiles.Create(chunkCount: 1); + using var secondFiles = TestClipFiles.Create(chunkCount: 1); + var second = WithEventAt(secondFiles.Clip, secondFiles.Clip.Chunks[0].Timestamp.AddSeconds(25)); + var front = new FakeCameraPlayer(); + var controller = BuildFourCameraController(front); + controller.LoadClips([firstFiles.Clip, second]); + var vm = new MainWindowViewModel(() => controller, backgroundYield: () => Task.CompletedTask, uiInvoker: action => action()); + vm.InitializePlayer(); + vm.Library.SelectedClip = firstFiles.Clip; + RunPinnedToTestThread(controller.WhenIdleAsync); + + vm.Playback.BeginSeek(); + vm.Playback.SeekPosition = 0.75; + vm.Playback.OnSeekSliderValueChanged(); + RunPinnedToTestThread(() => vm.Playback.NextCommand.ExecuteAsync(null)); + RunPinnedToTestThread(controller.WhenIdleAsync); + var seeksBeforeRelease = front.Seeks.Count; + + // The new clip opens 10 seconds before its event, and the thumb follows it while the mouse is still down. + controller.Position.ShouldBe(TimeSpan.FromSeconds(15)); + vm.Playback.SeekPosition.ShouldBe(0.25, 0.0001); + + RunPinnedToTestThread(vm.Playback.EndSeekAsync); + RunPinnedToTestThread(controller.WhenIdleAsync); + + front.Seeks.Count.ShouldBe(seeksBeforeRelease); + controller.Position.ShouldBe(TimeSpan.FromSeconds(15)); + controller.IsPlaying.ShouldBeTrue(); + } + + [Fact] + public void SeekThumbHeldWhileThePlaylistReloads_ReleasingIt_SeeksThereAndResumes() + { + // A reloaded playlist reports no current clip yet keeps the open clip, which the player holds paused until the release; dropping the gesture there would leave it paused while showing Pause. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out var controller, out var front); + + vm.Playback.BeginSeek(); + vm.Playback.SeekPosition = 0.5; + vm.Playback.SetPlaylist([clipFiles.Clip]); + RunPinnedToTestThread(vm.Playback.EndSeekAsync); + RunPinnedToTestThread(controller.WhenIdleAsync); + + front.Seeks[^1].ShouldBe((TimeSpan.FromSeconds(30), true)); + front.IsPlaying.ShouldBeTrue(); + } + + [Fact] + public void SeekBarRelease_WithNoGestureBegun_StillSeeksToTheThumb() + { + // WPF's Slider can handle a press on the seek-bar rail before BeginSeek runs, so a rail click may reach the view-model only as a thumb move and a release, and only a clip change may cancel that release. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var vm = CreateViewModelPlayingClip(clipFiles.Clip, out var controller, out var front); + RunPinnedToTestThread(() => vm.Playback.PlayPauseCommand.ExecuteAsync(null)); + + vm.Playback.SeekPosition = 0.5; + RunPinnedToTestThread(vm.Playback.EndSeekAsync); + RunPinnedToTestThread(controller.WhenIdleAsync); + + front.Seeks[^1].ShouldBe((TimeSpan.FromSeconds(30), true)); + vm.Playback.SeekPosition.ShouldBe(0.5, 0.0001); + } + // Selects the clip through the list and waits until the player has it open and playing. // The view-model's handlers run inline on whichever thread the controller raises them, because the open finishes on a thread-pool continuation. private MainWindowViewModel CreateViewModelPlayingClip(CamClip clip, out VideoPlayerController controller, out FakeCameraPlayer front) diff --git a/SentryDeck/ViewModels/PlaybackViewModel.cs b/SentryDeck/ViewModels/PlaybackViewModel.cs index fd94f71..451f6b6 100644 --- a/SentryDeck/ViewModels/PlaybackViewModel.cs +++ b/SentryDeck/ViewModels/PlaybackViewModel.cs @@ -28,6 +28,14 @@ public sealed partial class PlaybackViewModel : ObservableObject // EndSeekAsync's slow tail (the accurate seek can queue behind in-flight scrubs) may complete after the user has already started a NEW drag; only the completion belonging to the latest gesture may clear _isSeeking, or the position sync would yank the thumb out from under the active drag. private int _seekGeneration; + // The clip the current seek gesture began on. + // The thumb's position is a fraction of that clip's length, so it means nothing once the player moves to another clip. + private CamClip _seekGestureClip; + + // Set when a clip change ended the seek gesture while the thumb was still held, so the release that follows seeks nothing. + // Only a clip change makes the thumb's position meaningless: a release with no gesture begun (a click on the seek-bar rail, whose press the Slider can handle before BeginSeek runs) still seeks to where the thumb was put. + private bool _seekGestureEndedByClipChange; + // --- Seek-bar overlays for the selected clip (event moment + chunk seams + gaps) --- // Recomputed whenever the selection changes or the controller opens/replaces its media source; plain fields (not ObservableProperty) because they're derived, not independently settable. private double? _eventPosition; @@ -333,6 +341,8 @@ public void BeginSeek() { _seekGeneration++; _isSeeking = true; + _seekGestureClip = _playerController.CurrentClip; + _seekGestureEndedByClipChange = false; _scrubCoalescer.Reset(); // Holds playback paused for the gesture, so each scrub is one cheap paused seek and the release resumes every camera together. @@ -345,6 +355,14 @@ public async Task EndSeekAsync() { var generation = _seekGeneration; + // A clip change already ended this gesture, so there is nothing to release, and the new clip keeps the position it opened at. + if (_seekGestureEndedByClipChange) + { + _seekGestureEndedByClipChange = false; + UpdateSeekPositionFromController(); + return; + } + if (_playerController is null || !CanSeek) { _isSeeking = false; @@ -690,6 +708,25 @@ private void NotifyNavigationChanged() OnPropertyChanged(nameof(CanGoPrevious)); } + // Next or Previous from the keyboard can change the clip while the thumb is held. + // Releasing the thumb would then seek the new clip to the old clip's fraction, throwing away the position it opened at (the lead-in before its event), so the clip change ends the gesture instead. + // Ending it here also lets the thumb and time readout follow the new clip while the mouse is still down. + // A reloaded playlist reports no current clip but leaves the open clip as it was, still held paused for the gesture until the release resumes it, so only a move to another clip, which the controller opens afresh, ends the gesture. + private void EndSeekGestureIfClipChanged() + { + var clip = _playerController.CurrentClip; + if (!_isSeeking || clip is null || ReferenceEquals(clip, _seekGestureClip)) + return; + + _seekGeneration++; + _isSeeking = false; + _seekGestureClip = null; + _seekGestureEndedByClipChange = true; + + // A scrub value still queued behind an in-flight one would otherwise be issued against the new clip. + _scrubCoalescer.CancelPending(); + } + private void UpdateSeekPositionFromController() { if (_playerController is null || _isSeeking) @@ -766,6 +803,7 @@ private void HandlePlayerControllerPropertyChanged(string propertyName) break; case nameof(VideoPlayerController.CurrentClip): + EndSeekGestureIfClipChanged(); CurrentClipChanged?.Invoke(this, _playerController.CurrentClip); NowPlayingClip = _playerController.CurrentClip; NotifyNavigationChanged(); From ef2c01be64dcd7ded678e60b6ccca6663c073e07 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:17:59 -0500 Subject: [PATCH 40/73] Round the selected trim length to the nearest second The trim panel truncated the selected length to whole seconds, so a cut made with one 5 s step read "0:04" and a one-frame selection read "0:00 selected, ready to export". Marks land on frame boundaries, so a range is often a few milliseconds short of the length the user made. The length now rounds to the nearest second, and a range under a second reads "<1 s". Found in v1.1.0 local testing: F69. --- .../MainWindowViewModelTests.Trimming.cs | 31 +++++++++++++++++++ SentryDeck/ViewModels/TrimViewModel.cs | 11 +++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs index 3dad307..32c251a 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs @@ -217,6 +217,37 @@ public void TrimHintText_WalksThroughStartEndExport() vm.Trim.TrimHintText.ShouldBe("1:00 selected — ready to export."); } + [Fact] + public void SelectionDurationText_RangeJustShortOfAWholeSecond_RoundsToIt() + { + // Marks land on frame boundaries, so a cut made with one 5 s step often comes out a few milliseconds short. + var vm = CreateViewModelWithController(out var controller, out _); + controller.Duration = TimeSpan.FromMinutes(1); + controller.IsMediaOpen = true; + + vm.Playback.SeekPosition = 0.1; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.1 + (4.98 / 60); + vm.Trim.MarkSelectionEndCommand.Execute(null); + + vm.Trim.SelectionDurationText.ShouldBe("0:05"); + } + + [Fact] + public void SelectionDurationText_RangeUnderASecond_ReadsLessThanOneSecond() + { + var vm = CreateViewModelWithController(out var controller, out _); + controller.Duration = TimeSpan.FromMinutes(1); + controller.IsMediaOpen = true; + + vm.Playback.SeekPosition = 0.5; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.5 + (0.04 / 60); // one frame + vm.Trim.MarkSelectionEndCommand.Execute(null); + + vm.Trim.SelectionDurationText.ShouldBe("<1 s"); + } + [Fact] public void MarkSelection_RequiresSeekableMedia() { diff --git a/SentryDeck/ViewModels/TrimViewModel.cs b/SentryDeck/ViewModels/TrimViewModel.cs index 2259714..39ef547 100644 --- a/SentryDeck/ViewModels/TrimViewModel.cs +++ b/SentryDeck/ViewModels/TrimViewModel.cs @@ -96,7 +96,7 @@ public string TrimHintText } } - /// Length of the marked range, e.g. "0:42" (empty until both marks are set). + /// Length of the marked range to the nearest second, e.g. "0:42", or "<1 s" for a sliver (empty until both marks are set). public string SelectionDurationText { get @@ -106,8 +106,13 @@ public string SelectionDurationText return string.Empty; } - var duration = _playback.Duration; - return PlaybackViewModel.FormatTimeSpan(TimeSpan.FromSeconds((end - start) * duration.TotalSeconds)); + var length = TimeSpan.FromSeconds((end - start) * _playback.Duration.TotalSeconds); + + // Marks land on frame boundaries, so a cut made as 5 s is often a few milliseconds short, and truncating would call it 0:04. + // Under a second, any whole-second figure misdescribes a range of a few frames. + return length < TimeSpan.FromSeconds(1) + ? "<1 s" + : PlaybackViewModel.FormatTimeSpan(TimeSpan.FromSeconds(Math.Round(length.TotalSeconds, MidpointRounding.AwayFromZero))); } } From 339c98de88f1d688e21970db02623acd20afdc8c Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:19:43 -0500 Subject: [PATCH 41/73] Say which camera an export saves Nothing in the trim panel named the camera an export would save. In grid view the export quietly saves the front camera alone, because stream copy can't composite the grid, and "Save event clip" always saves the front camera even though the app focuses the camera that triggered the event. File names also used the internal suffixes ("back", "left repeater") while the tiles say Rear and Left. Once both marks are set, the trim hint now reads like "0:42 of the Front camera selected, ready to export." and follows camera switches. Export and event clip file names use the tiles' camera names, and the event clip's name says "front event". Found in v1.1.0 local testing: F70. --- .../MainWindowViewModelTests.Trimming.cs | 83 ++++++++++++++++++- SentryDeck/ViewModels/CameraViewsViewModel.cs | 2 +- SentryDeck/ViewModels/TrimViewModel.cs | 21 ++++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs index 32c251a..2f8a677 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs @@ -214,7 +214,43 @@ public void TrimHintText_WalksThroughStartEndExport() // Half of a 2:00 clip is selected. vm.Trim.SelectionDurationText.ShouldBe("1:00"); - vm.Trim.TrimHintText.ShouldBe("1:00 selected — ready to export."); + vm.Trim.TrimHintText.ShouldBe("1:00 of the Front camera selected, ready to export."); + } + + [Fact] + public void TrimHintText_GridView_NamesTheFrontCameraTheExportSaves() + { + // Stream copy can't composite the grid, so a grid export saves the front camera alone; the panel must say so before the user exports. + var vm = CreateViewModelWithController(out var controller, out _); + controller.Duration = TimeSpan.FromMinutes(2); + controller.IsMediaOpen = true; + vm.Cameras.SelectCameraViewCommand.Execute(CameraViewsViewModel.GridCameraView); + + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); + + vm.Trim.TrimHintText.ShouldBe("1:00 of the Front camera selected, ready to export."); + } + + [Fact] + public void TrimHintText_CameraSwitchedAfterMarking_NamesTheNewCamera() + { + var vm = CreateViewModelWithController(out var controller, out _); + controller.Duration = TimeSpan.FromMinutes(2); + controller.IsMediaOpen = true; + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); + var changed = new List(); + vm.Trim.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + vm.Cameras.SelectCameraViewCommand.Execute(CameraNames.Back); + + changed.ShouldContain(nameof(TrimViewModel.TrimHintText)); + vm.Trim.TrimHintText.ShouldBe("1:00 of the Rear camera selected, ready to export."); } [Fact] @@ -283,6 +319,30 @@ public void ExportSelection_SendsMediaTimeRangeAndActiveCameraToTheExporter() vm.Trim.IsExporting.ShouldBeFalse(); } + [Fact] + public void ExportSelection_DefaultFileName_UsesTheCameraNameFromTheTiles() + { + // The tiles say "Rear", so a file named after the internal "back" camera reads like a different angle. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + string suggested = null; + var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, new FakeClipExporter(), name => + { + suggested = name; + return 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.Trim.ExportSelectionCommand.ExecuteAsync(null)); + + Path.GetFileName(suggested).ShouldContain(" rear "); + Path.GetFileName(suggested).ShouldNotContain("back"); + } + [Fact] public void ExportSelection_SaveDialogCanceled_DoesNotExport() { @@ -346,6 +406,27 @@ public async Task SaveEventClip_ExportsFrontCameraWindowAroundTheEvent() request.OutputPath.ShouldBe(@"C:\out\event.mp4"); } + [Fact] + public async Task SaveEventClip_DefaultFileName_NamesTheFrontCamera() + { + // The app focuses the camera that triggered the event, so a file name that doesn't name the exported camera hides that only the front was saved. + var clip = ClipWithChunksAndEvent(chunkCount: 1, eventOffset: TimeSpan.FromSeconds(10)); + string suggested = null; + var vm = new MainWindowViewModel( + () => null!, + clipExporter: new FakeClipExporter(), + savePathPicker: name => + { + suggested = name; + return null; + }, + exportMediaSourceBuilder: new FakeClipMediaSourceBuilder()); + + await vm.Trim.SaveEventClipCommand.ExecuteAsync(clip); + + Path.GetFileName(suggested).ShouldContain(" front event"); + } + [Fact] public async Task SaveEventClip_WindowIsClampedToTheClip() { diff --git a/SentryDeck/ViewModels/CameraViewsViewModel.cs b/SentryDeck/ViewModels/CameraViewsViewModel.cs index cfdfc07..49cb8f9 100644 --- a/SentryDeck/ViewModels/CameraViewsViewModel.cs +++ b/SentryDeck/ViewModels/CameraViewsViewModel.cs @@ -120,7 +120,7 @@ private void SelectCameraView(string cameraView) /// Friendly tile label for a camera. /// The classic four keep their short historical names; the HW4/AI4 B-pillars are spelled out to distinguish them from the repeaters. /// - private static string CameraLabel(string camera) => camera switch + internal static string CameraLabel(string camera) => camera switch { CameraNames.Front => "Front", CameraNames.Back => "Rear", diff --git a/SentryDeck/ViewModels/TrimViewModel.cs b/SentryDeck/ViewModels/TrimViewModel.cs index 39ef547..2b547c6 100644 --- a/SentryDeck/ViewModels/TrimViewModel.cs +++ b/SentryDeck/ViewModels/TrimViewModel.cs @@ -50,6 +50,7 @@ public TrimViewModel( _exportMediaSourceBuilder = exportMediaSourceBuilder; _playback.PropertyChanged += OnPlaybackPropertyChanged; + _cameras.PropertyChanged += OnCamerasPropertyChanged; } /// The selection start as a 0..1 fraction of the clip timeline (0 when unset; pair with ). @@ -71,7 +72,7 @@ public TrimViewModel( public bool CanExportSelection => HasSelection && !IsExporting && CanSeek; /// - /// One-line guidance for the trim panel: walks the user through start → end → export, and shows the selected length once the range is complete. + /// One-line guidance for the trim panel: walks the user through start → end → export, and shows the selected length and the camera it will be saved from once the range is complete. /// public string TrimHintText { @@ -79,7 +80,8 @@ public string TrimHintText { if (HasSelection) { - return $"{SelectionDurationText} selected — ready to export."; + // Naming the camera matters most in the grid: stream copy can't composite it, so the export quietly saves the front camera alone. + return $"{SelectionDurationText} of the {CameraViewsViewModel.CameraLabel(_cameras.ExportCamera)} camera selected, ready to export."; } if (HasSelectionStart) @@ -215,7 +217,7 @@ private async Task ExportSelectionAsync() var start = TimeSpan.FromSeconds(startFraction * mediaSource.Duration.TotalSeconds); var end = TimeSpan.FromSeconds(endFraction * mediaSource.Duration.TotalSeconds); var camera = _cameras.ExportCamera; - var defaultFileName = $"{clip.Name} {CameraNames.DisplayName(camera)} {FormatTimeSpanForFileName(start)}-{FormatTimeSpanForFileName(end)}.mp4"; + var defaultFileName = $"{clip.Name} {FileCameraName(camera)} {FormatTimeSpanForFileName(start)}-{FormatTimeSpanForFileName(end)}.mp4"; await ExportAsync(clip, mediaSource, camera, start, end, defaultFileName); } @@ -267,7 +269,7 @@ private async Task SaveEventClipAsync(CamClip clip) end = mediaSource.Duration; } - await ExportAsync(clip, mediaSource, CameraNames.Front, start, end, $"{clip.Name} event.mp4"); + await ExportAsync(clip, mediaSource, CameraNames.Front, start, end, $"{clip.Name} {FileCameraName(CameraNames.Front)} event.mp4"); } private bool CanSaveEventClip(CamClip clip) => @@ -324,6 +326,9 @@ private static string PickSavePathWithDialog(string defaultFileName) private static string FormatTimeSpanForFileName(TimeSpan ts) => PlaybackViewModel.FormatTimeSpan(ts).Replace(':', '.'); + // The tiles' names ("rear", "left"), not the file suffixes ("back", "left repeater"), so the saved file names the angle the user picked. + private static string FileCameraName(string camera) => CameraViewsViewModel.CameraLabel(camera).ToLowerInvariant(); + private static string SanitizeFileName(string name) { foreach (var invalid in Path.GetInvalidFileNameChars()) @@ -360,6 +365,14 @@ private void OnPlaybackPropertyChanged(object sender, PropertyChangedEventArgs e } } + private void OnCamerasPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(CameraViewsViewModel.ExportCamera)) + { + OnPropertyChanged(nameof(TrimHintText)); + } + } + private void NotifySelectionChanged() { OnPropertyChanged(nameof(SelectionStartPosition)); From 2e78421d4a24eb0da27a30868268cfb69ae094f2 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:22:21 -0500 Subject: [PATCH 42/73] Suggest exports in the Videos folder with a sortable name The export dialog opened wherever the app last browsed, which is usually the TeslaCam folder itself, so exports landed on the car's USB drive by default. The suggested name was "12_16_2025 15_53_27 front 0.39-2.01.mp4": US date order that doesn't sort, and "0.39" meaning 0:39. "Save event clip" suggested "12_16_2025 15_53_27 event.mp4" with no range at all. The dialog now starts in the user's Videos folder, or in the folder of the last export this session while it still exists. Names are year-first like Tesla's folders and give the range in minutes and seconds, rounded to the nearest second: "2025-12-16_15-53-27 front 0m39s-2m01s.mp4", and "2025-12-16_15-53-27 front event 1m00s-2m00s.mp4" for an event clip. Found in v1.1.0 local testing: lead #2. --- .../MainWindowViewModelTests.Trimming.cs | 90 +++++++++++++++++++ SentryDeck/MainWindowViewModel.cs | 2 +- SentryDeck/ViewModels/TrimViewModel.cs | 40 +++++++-- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs index 2f8a677..fcfa3c4 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs @@ -343,6 +343,75 @@ public void ExportSelection_DefaultFileName_UsesTheCameraNameFromTheTiles() Path.GetFileName(suggested).ShouldNotContain("back"); } + [Fact] + public void ExportSelection_FirstExport_SuggestsASortableNameInTheVideosFolder() + { + // Without a folder of its own, the save dialog reopens wherever the app last browsed, which is the dashcam drive the car may reformat. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); // 3:00 of footage from 2023-02-23 14:14:48 + string suggested = null; + var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, new FakeClipExporter(), path => + { + suggested = path; + return null; + }); + + vm.Playback.SeekPosition = 39.4 / 180; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 120.6 / 180; + vm.Trim.MarkSelectionEndCommand.Execute(null); + + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); + + var videos = Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); + suggested.ShouldBe(Path.Combine(videos, "2023-02-23_14-14-48 front 0m39s-2m01s.mp4")); + } + + [Fact] + public void ExportSelection_AfterAnExport_SuggestsTheFolderLastExportedTo() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var exportFolder = clipFiles.RootPath; + var suggestions = new List(); + var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, new FakeClipExporter(), path => + { + suggestions.Add(path); + return Path.Combine(exportFolder, "first.mp4"); + }); + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); + + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); + + suggestions.Count.ShouldBe(2); + Path.GetDirectoryName(suggestions[1]).ShouldBe(exportFolder); + } + + [Fact] + public void ExportSelection_LastExportFolderIsGone_SuggestsTheVideosFolderAgain() + { + // A folder on a drive that has since been unplugged can't open in the dialog. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var missingFolder = Path.Combine(clipFiles.RootPath, "unplugged"); + var suggestions = new List(); + var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, new FakeClipExporter(), path => + { + suggestions.Add(path); + return Path.Combine(missingFolder, "first.mp4"); + }); + vm.Playback.SeekPosition = 0.25; + vm.Trim.MarkSelectionStartCommand.Execute(null); + vm.Playback.SeekPosition = 0.75; + vm.Trim.MarkSelectionEndCommand.Execute(null); + + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); + RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); + + Path.GetDirectoryName(suggestions[1]).ShouldBe(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos)); + } + [Fact] public void ExportSelection_SaveDialogCanceled_DoesNotExport() { @@ -427,6 +496,27 @@ public async Task SaveEventClip_DefaultFileName_NamesTheFrontCamera() Path.GetFileName(suggested).ShouldContain(" front event"); } + [Fact] + public async Task SaveEventClip_DefaultFileName_IsSortableAndNamesTheRange() + { + // 3-chunk clip from 2025-01-01 12:00:00 with the event 90s in: the ±30s window is 1:00-2:00. + var clip = ClipWithChunksAndEvent(chunkCount: 3, eventOffset: TimeSpan.FromSeconds(90)); + string suggested = null; + var vm = new MainWindowViewModel( + () => null!, + clipExporter: new FakeClipExporter(), + savePathPicker: path => + { + suggested = path; + return null; + }, + exportMediaSourceBuilder: new FakeClipMediaSourceBuilder()); + + await vm.Trim.SaveEventClipCommand.ExecuteAsync(clip); + + Path.GetFileName(suggested).ShouldBe("2025-01-01_12-00-00 front event 1m00s-2m00s.mp4"); + } + [Fact] public async Task SaveEventClip_WindowIsClampedToTheClip() { diff --git a/SentryDeck/MainWindowViewModel.cs b/SentryDeck/MainWindowViewModel.cs index 19c7734..20dc9e9 100644 --- a/SentryDeck/MainWindowViewModel.cs +++ b/SentryDeck/MainWindowViewModel.cs @@ -28,7 +28,7 @@ public partial class MainWindowViewModel : ObservableObject /// Overridable for tests. /// Exports trimmed clip ranges. /// Defaults to the FFmpeg-backed exporter; overridable for tests. - /// Maps a suggested file name to the chosen save path (null = canceled). + /// Maps a suggested save path (folder and file name) to the chosen one (null = canceled). /// Defaults to a save dialog; overridable for tests. /// Builds a media source for exporting a clip that isn't currently open. /// Overridable for tests. diff --git a/SentryDeck/ViewModels/TrimViewModel.cs b/SentryDeck/ViewModels/TrimViewModel.cs index 2b547c6..ac6d090 100644 --- a/SentryDeck/ViewModels/TrimViewModel.cs +++ b/SentryDeck/ViewModels/TrimViewModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Diagnostics; +using System.Globalization; using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -28,11 +29,14 @@ public sealed partial class TrimViewModel : ObservableObject private double? _selectionStart; private double? _selectionEnd; + // Where the user saved their last export this session, so a run of exports from one drive doesn't make them browse back each time. + private string _lastExportDirectory; + /// Supplies the playhead the marks are set at, and the open clip an export reads from. /// Decides which camera a range export uses. /// Where export failures are reported. /// Exports trimmed clip ranges. - /// Maps a suggested file name to the chosen save path (null = canceled). + /// Maps a suggested save path (folder and file name) to the chosen one (null = canceled). /// Builds a media source for exporting a clip that isn't currently open. public TrimViewModel( PlaybackViewModel playback, @@ -217,7 +221,7 @@ private async Task ExportSelectionAsync() var start = TimeSpan.FromSeconds(startFraction * mediaSource.Duration.TotalSeconds); var end = TimeSpan.FromSeconds(endFraction * mediaSource.Duration.TotalSeconds); var camera = _cameras.ExportCamera; - var defaultFileName = $"{clip.Name} {FileCameraName(camera)} {FormatTimeSpanForFileName(start)}-{FormatTimeSpanForFileName(end)}.mp4"; + var defaultFileName = $"{FileNameStamp(clip)} {FileCameraName(camera)} {FormatRangeForFileName(start, end)}.mp4"; await ExportAsync(clip, mediaSource, camera, start, end, defaultFileName); } @@ -269,7 +273,7 @@ private async Task SaveEventClipAsync(CamClip clip) end = mediaSource.Duration; } - await ExportAsync(clip, mediaSource, CameraNames.Front, start, end, $"{clip.Name} {FileCameraName(CameraNames.Front)} event.mp4"); + await ExportAsync(clip, mediaSource, CameraNames.Front, start, end, $"{FileNameStamp(clip)} {FileCameraName(CameraNames.Front)} event {FormatRangeForFileName(start, end)}.mp4"); } private bool CanSaveEventClip(CamClip clip) => @@ -277,12 +281,14 @@ private bool CanSaveEventClip(CamClip clip) => private async Task ExportAsync(CamClip clip, ClipMediaSource mediaSource, string camera, TimeSpan start, TimeSpan end, string defaultFileName) { - var outputPath = _savePathPicker(SanitizeFileName(defaultFileName)); + var outputPath = _savePathPicker(Path.Combine(SuggestExportDirectory(), SanitizeFileName(defaultFileName))); if (string.IsNullOrEmpty(outputPath)) { return; } + _lastExportDirectory = Path.GetDirectoryName(outputPath); + IsExporting = true; try @@ -311,12 +317,20 @@ private async Task ExportAsync(CamClip clip, ClipMediaSource mediaSource, string } } - private static string PickSavePathWithDialog(string defaultFileName) + // Without a folder of its own, the save dialog reopens wherever the app last browsed, which is usually the dashcam drive the car may reformat. + // So exports start in the user's Videos folder, or where they last saved one while that folder still exists (a drive may have been unplugged since). + private string SuggestExportDirectory() => + _lastExportDirectory is { } last && Directory.Exists(last) + ? last + : Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); + + private static string PickSavePathWithDialog(string suggestedPath) { var dialog = new SaveFileDialog { Title = "Export clip", - FileName = defaultFileName, + InitialDirectory = Path.GetDirectoryName(suggestedPath), + FileName = Path.GetFileName(suggestedPath), DefaultExt = ".mp4", Filter = "MP4 video|*.mp4", }; @@ -324,7 +338,19 @@ private static string PickSavePathWithDialog(string defaultFileName) return dialog.ShowDialog() == true ? dialog.FileName : null; } - private static string FormatTimeSpanForFileName(TimeSpan ts) => PlaybackViewModel.FormatTimeSpan(ts).Replace(':', '.'); + // Year-first, like Tesla's own folder names, so exports sort by date in Explorer whatever the user's locale. + private static string FileNameStamp(CamClip clip) => + clip.Timestamp == default ? clip.Name : clip.Timestamp.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); + + // "0m39s-2m01s": colons aren't allowed in file names, and the old "0.39" for 0:39 read like a decimal. + private static string FormatRangeForFileName(TimeSpan start, TimeSpan end) => + $"{FormatOffsetForFileName(start)}-{FormatOffsetForFileName(end)}"; + + private static string FormatOffsetForFileName(TimeSpan offset) + { + var seconds = (long)Math.Round(offset.TotalSeconds, MidpointRounding.AwayFromZero); + return string.Create(CultureInfo.InvariantCulture, $"{seconds / 60}m{seconds % 60:00}s"); + } // The tiles' names ("rear", "left"), not the file suffixes ("back", "left repeater"), so the saved file names the angle the user picked. private static string FileCameraName(string camera) => CameraViewsViewModel.CameraLabel(camera).ToLowerInvariant(); From 8d932970835a325f7af46138f031ac760c68ed0b Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 22:27:40 -0500 Subject: [PATCH 43/73] Don't let a failed export hide the playing clip "Save event clip" stayed enabled for a clip whose event falls outside its footage, even while that clip was open with Jump to event disabled. Clicking it showed a full-pane "Export Failed" that hid the clip still playing behind it until dismissed, and nothing was logged. Every other export failure did the same, because the error overlay can't draw over the native video surface, so showing it means hiding the video. For the open clip, whose footage is already probed, the item is now disabled when its event can't be placed. Other clips aren't probed until an export builds their source, so they report it then. Export problems now show in a strip under the video with a Dismiss button, and an event that can't be placed is logged. Events stamped a moment past the last frame are mapped to the end separately (F58); this covers the ones still outside. Found in v1.1.0 local testing: F67. --- .../MainWindowViewModelTests.Trimming.cs | 102 ++++++++++++++++-- SentryDeck/MainWindow.xaml | 35 ++++++ SentryDeck/MainWindowViewModel.cs | 3 +- .../ViewModels/ErrorOverlayViewModel.cs | 3 +- SentryDeck/ViewModels/TrimViewModel.cs | 45 ++++++-- 5 files changed, 168 insertions(+), 20 deletions(-) diff --git a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs index fcfa3c4..0790bf0 100644 --- a/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs +++ b/SentryDeck.Tests/MainWindowViewModelTests.Trimming.cs @@ -431,8 +431,9 @@ public void ExportSelection_SaveDialogCanceled_DoesNotExport() } [Fact] - public void ExportSelection_ExporterFailure_ShowsErrorAndResetsBusyState() + public void ExportSelection_ExporterFailure_ReportsItWithoutHidingTheVideo() { + // The error overlay can't draw over the native video surface, so showing a failed export there would blank the clip that is still playing. using var clipFiles = TestClipFiles.Create(chunkCount: 1); var exporter = new FakeClipExporter { ExceptionToThrow = new InvalidOperationException("ffmpeg exploded") }; var (vm, _, _) = CreateViewModelWithOpenedClip(clipFiles.Clip, exporter, _ => @"C:\out\clip.mp4"); @@ -444,9 +445,9 @@ public void ExportSelection_ExporterFailure_ShowsErrorAndResetsBusyState() RunPinnedToTestThread(() => vm.Trim.ExportSelectionCommand.ExecuteAsync(null)); - vm.Error.IsVisible.ShouldBeTrue(); - vm.Error.Title.ShouldBe("Export Failed"); - vm.Error.Details.ShouldContain("ffmpeg exploded"); + vm.Error.IsVisible.ShouldBeFalse(); + vm.Trim.HasExportNotice.ShouldBeTrue(); + vm.Trim.ExportNotice.ShouldContain("ffmpeg exploded"); vm.Trim.IsExporting.ShouldBeFalse(); } @@ -540,10 +541,10 @@ public async Task SaveEventClip_WindowIsClampedToTheClip() } [Fact] - public async Task SaveEventClip_BuilderThrows_ShowsErrorInsteadOfCrashing() + public async Task SaveEventClip_BuilderThrows_ReportsItInsteadOfCrashing() { // Building an unopened clip's media source does real IO and can throw (drive unplugged, temp write fails). - // It must surface an Export Failed dialog, not escape to the dispatcher. + // It must be reported, not escape to the dispatcher. var clip = ClipWithChunksAndEvent(chunkCount: 1, eventOffset: TimeSpan.FromSeconds(10)); var exporter = new FakeClipExporter(); var vm = new MainWindowViewModel( @@ -557,12 +558,97 @@ public async Task SaveEventClip_BuilderThrows_ShowsErrorInsteadOfCrashing() await vm.Trim.SaveEventClipCommand.ExecuteAsync(clip); - vm.Error.IsVisible.ShouldBeTrue(); - vm.Error.Title.ShouldBe("Export Failed"); + vm.Error.IsVisible.ShouldBeFalse(); + vm.Trim.ExportNotice.ShouldContain("drive gone"); exporter.Requests.ShouldBeEmpty(); vm.Trim.IsExporting.ShouldBeFalse(); } + [Fact] + public async Task SaveEventClip_EventOutsideAnUnopenedClipsFootage_ReportsItWithoutHidingTheVideo() + { + // Only building the clip's source shows where its footage ends, so this one fails after the click; the clip playing meanwhile must stay visible. + var clip = ClipWithChunksAndEvent(chunkCount: 1, eventOffset: TimeSpan.FromMinutes(10)); + var exporter = new FakeClipExporter(); + var pickerOpened = false; + var vm = new MainWindowViewModel( + () => null!, + clipExporter: exporter, + savePathPicker: _ => + { + pickerOpened = true; + return null; + }, + exportMediaSourceBuilder: new FakeClipMediaSourceBuilder()); + + await vm.Trim.SaveEventClipCommand.ExecuteAsync(clip); + + vm.Error.IsVisible.ShouldBeFalse(); + vm.Trim.ExportNotice.ShouldContain("isn't within its saved footage"); + pickerOpened.ShouldBeFalse(); + exporter.Requests.ShouldBeEmpty(); + } + + [Fact] + public async Task ExportNotice_DismissedOrSupersededByTheNextExport_Clears() + { + var unplaceable = ClipWithChunksAndEvent(chunkCount: 1, eventOffset: TimeSpan.FromMinutes(10)); + var placeable = ClipWithChunksAndEvent(chunkCount: 1, eventOffset: TimeSpan.FromSeconds(10)); + var vm = new MainWindowViewModel( + () => null!, + clipExporter: new FakeClipExporter(), + savePathPicker: _ => null, + exportMediaSourceBuilder: new FakeClipMediaSourceBuilder()); + + await vm.Trim.SaveEventClipCommand.ExecuteAsync(unplaceable); + vm.Trim.DismissExportNoticeCommand.Execute(null); + vm.Trim.HasExportNotice.ShouldBeFalse(); + + await vm.Trim.SaveEventClipCommand.ExecuteAsync(unplaceable); + await vm.Trim.SaveEventClipCommand.ExecuteAsync(placeable); + vm.Trim.HasExportNotice.ShouldBeFalse(); + } + + [Fact] + public void SaveEventClip_OpenClipWhoseEventIsOutsideItsFootage_IsDisabled() + { + // The open clip's footage is already probed, so the menu can tell up front instead of failing after the click. + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Timestamp.AddMinutes(10)); + var (vm, _, _) = CreateViewModelWithOpenedClip(clip); + + vm.Trim.SaveEventClipCommand.CanExecute(clip).ShouldBeFalse(); + } + + [Fact] + public void SaveEventClip_OpenClipWhoseEventIsInItsFootage_IsEnabled() + { + using var clipFiles = TestClipFiles.Create(chunkCount: 1); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Timestamp.AddSeconds(30)); + var (vm, _, _) = CreateViewModelWithOpenedClip(clip); + + vm.Trim.SaveEventClipCommand.CanExecute(clip).ShouldBeTrue(); + } + + [Fact] + public void SaveEventClip_WhenTheOpenClipsMediaIsRebuilt_IsRequeried() + { + // The context menu only asks again when told to, so an item enabled before the footage was probed would otherwise stay enabled. + using var clipFiles = TestClipFiles.Create(chunkCount: 3); + var clip = WithEventAt(clipFiles.Clip, clipFiles.Clip.Timestamp.AddMinutes(10)); + var (vm, controller, front) = CreateViewModelWithOpenedClip(clip, uiInvoker: action => action()); + RunPinnedToTestThread(controller.PauseAsync); + var requeried = false; + vm.Trim.SaveEventClipCommand.CanExecuteChanged += (_, _) => requeried = true; + + // Ending 90s into 180s of footage makes recovery exclude the middle chunk and reopen the clip. + front.RaisePositionChanged(TimeSpan.FromSeconds(90)); + front.RaiseEnded(); + RunPinnedToTestThread(controller.WhenIdleAsync); + + requeried.ShouldBeTrue(); + } + private sealed class ThrowingClipMediaSourceBuilder(Exception exception) : IClipMediaSourceBuilder { public ClipMediaSource Build(CamClip clip, IReadOnlySet excludedChunkIndices = null) => throw exception; diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index 846b625..54ff796 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -1349,6 +1349,41 @@ + + + + + + + + + + + + +/// +/// A readout as wide as its text shifted the seek bar and its markers each time the position gained a digit, such as when playback crossed 10:00. +/// A fixed width that fits a 10-minute clip took about 15 px from the seek bar on every shorter clip, and the bar is already narrow in a small window. +/// The position never has more digits than the duration, so the duration alone sets the widest text for the whole clip. +/// +public sealed class TimeReadoutWidthConverter : MarkupExtension, IMultiValueConverter +{ + public override object ProvideValue(IServiceProvider serviceProvider) => this; + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length < 2 || values[0] is not string duration || values[1] is not TextBlock readout) + { + return 0d; + } + + var widest = new FormattedText( + WidestText(duration, Separator(readout)), + culture ?? CultureInfo.CurrentCulture, + readout.FlowDirection, + new Typeface(readout.FontFamily, readout.FontStyle, readout.FontWeight, readout.FontStretch), + readout.FontSize, + Brushes.Black, + VisualTreeHelper.GetDpi(readout).PixelsPerDip); + + // Rounded up, so layout rounding can't leave the readout a fraction of a pixel narrower than its longest text. + return Math.Ceiling(widest.WidthIncludingTrailingWhitespace); + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotSupportedException(); + + /// + /// The longest text the readout shows for a clip of this duration, e.g. "0:00 / 0:00" for a 4:02 clip and "00:00 / 00:00" for a 10:33 one. + /// + /// + /// Its digits are all zeros so that clips of one length class get one width, which holds as long as the font's digits share a width; the readout uses Consolas, where every character does. + /// + internal static string WidestText(string duration, string separator) + { + var shape = string.Concat(duration.Select(character => char.IsAsciiDigit(character) ? '0' : character)); + return $"{shape}{separator}{shape}"; + } + + // Read from the readout rather than assumed, because XAML adds a space between runs written on separate lines, and a width one character short brings the shifting back. + private static string Separator(TextBlock readout) => + string.Concat(readout.Inlines.OfType().Skip(1).SkipLast(1).Select(run => run.Text)); +} From 6b636d150ca0c066632cdb67abafe1413a898c9a Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:02:11 -0500 Subject: [PATCH 56/73] Scroll the clip list to the clip that Next or Previous opened Pressing Next or Previous (or Ctrl+Right/Left) opened a clip whose row the list never scrolled to. After a few steps no visible row was highlighted and nothing on screen said which clip was playing, so finding it meant scrolling through the whole list. When the keyboard was in the list, its focus also stayed on the previous clip's row, so the arrow keys carried on from there. WPF only brings a row into view when the user picks it in the list itself, and nothing in the app asked it to for a selection made any other way. The list now scrolls to every newly selected clip once its rows are laid out, which works for the grouped, virtualized list where the row may not exist yet. While the keyboard is in the list, focus moves to the new row as well. Found in v1.1.0 local testing: F42, F08, F95, F116. --- SentryDeck.Tests/Fixtures/StaThread.cs | 58 ++++++++++ SentryDeck.Tests/MainWindowClipListTests.cs | 108 ++++++++++++++++++ .../MainWindowSeekGestureTests.cs | 35 +----- SentryDeck/MainWindow.xaml.cs | 30 ++++- 4 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 SentryDeck.Tests/Fixtures/StaThread.cs create mode 100644 SentryDeck.Tests/MainWindowClipListTests.cs diff --git a/SentryDeck.Tests/Fixtures/StaThread.cs b/SentryDeck.Tests/Fixtures/StaThread.cs new file mode 100644 index 0000000..55a3ba2 --- /dev/null +++ b/SentryDeck.Tests/Fixtures/StaThread.cs @@ -0,0 +1,58 @@ +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows; +using System.Windows.Threading; + +namespace SentryDeck.Tests; + +internal static class StaThread +{ + /// + /// Runs the action on a fresh STA thread and rethrows its failure on the test's thread. + /// WPF controls need an STA thread, which xUnit's test threads are not. + /// + public static void Run(Action action) + { + ExceptionDispatchInfo failure = null; + + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + failure = ExceptionDispatchInfo.Capture(ex); + } + finally + { + Dispatcher.CurrentDispatcher.InvokeShutdown(); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + failure?.Throw(); + } + + /// + /// Lets the dispatcher run everything already queued (deferred callbacks and layout passes) before the test looks at the result. + /// + /// + /// The element has no window, so nothing renders it; the explicit layout pass stands in for the one a shown window would get. + /// Several rounds let work queued by one round (a scroll that asks for a new layout) finish too. + /// + public static void DrainDispatcher(UIElement element) + { + for (var round = 0; round < 3; round++) + { + var frame = new DispatcherFrame(); + Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, () => frame.Continue = false); + Dispatcher.PushFrame(frame); + element.UpdateLayout(); + } + } +} diff --git a/SentryDeck.Tests/MainWindowClipListTests.cs b/SentryDeck.Tests/MainWindowClipListTests.cs new file mode 100644 index 0000000..f0e791b --- /dev/null +++ b/SentryDeck.Tests/MainWindowClipListTests.cs @@ -0,0 +1,108 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; + +namespace SentryDeck.Tests; + +/// +/// The clip list's scrolling, exercised on a real grouped, virtualizing ListBox because what it guards against is WPF leaving a programmatic selection off-screen. +/// +public sealed class MainWindowClipListTests +{ + private const double ListHeight = 200; + + [Fact] + public void KeepSelectionInView_RowSelectedFarBelowTheView_ScrollsItIntoView() + { + StaThread.Run(() => + { + var rows = CreateRows(120); + var list = CreateGroupedList(rows); + MainWindow.KeepSelectionInView(list); + + // Next and Previous select through the view-model, the way this assignment does, not by a click on the row. + list.SelectedItem = rows[100]; + StaThread.DrainDispatcher(list); + + IsFullyVisible(list, rows[100]).ShouldBeTrue(); + }); + } + + [Fact] + public void KeepSelectionInView_RowSelectedAboveTheView_ScrollsBackUpToIt() + { + StaThread.Run(() => + { + var rows = CreateRows(120); + var list = CreateGroupedList(rows); + MainWindow.KeepSelectionInView(list); + + // The user scrolled down to browse older days. + Descendants(list).OfType().First().ScrollToEnd(); + StaThread.DrainDispatcher(list); + + list.SelectedItem = rows[3]; + StaThread.DrainDispatcher(list); + + IsFullyVisible(list, rows[3]).ShouldBeTrue(); + }); + } + + private static List CreateRows(int count) => [.. Enumerable.Range(0, count).Select(index => new Row(index))]; + + // Mirrors the clip list: rows grouped by day, virtualized even while grouping, with recycled containers. + private static ListBox CreateGroupedList(List rows) + { + var view = new ListCollectionView(rows); + view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(Row.Day))); + + var list = new ListBox(); + + // Initializing it the way XAML does is what gives a list outside any window its default template, and with it the ScrollViewer that does the scrolling. + list.BeginInit(); + list.ItemsSource = view; + list.GroupStyle.Add(new GroupStyle()); + VirtualizingPanel.SetIsVirtualizingWhenGrouping(list, true); + VirtualizingPanel.SetVirtualizationMode(list, VirtualizationMode.Recycling); + list.EndInit(); + + list.Measure(new Size(200, ListHeight)); + list.Arrange(new Rect(0, 0, 200, ListHeight)); + list.UpdateLayout(); + return list; + } + + private static bool IsFullyVisible(ListBox list, Row row) + { + var scrollViewer = Descendants(list).OfType().First(); + var container = Descendants(list).OfType().FirstOrDefault(item => ReferenceEquals(item.DataContext, row)); + if (container is null) + { + return false; + } + + var bounds = container.TransformToAncestor(scrollViewer).TransformBounds(new Rect(container.RenderSize)); + return bounds.Top >= 0 && bounds.Bottom <= scrollViewer.ActualHeight; + } + + private static IEnumerable Descendants(DependencyObject parent) + { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + yield return child; + + foreach (var descendant in Descendants(child)) + { + yield return descendant; + } + } + } + + private sealed record Row(int Index) + { + // A handful of rows per group, like several clips on one day. + public int Day => Index / 8; + } +} diff --git a/SentryDeck.Tests/MainWindowSeekGestureTests.cs b/SentryDeck.Tests/MainWindowSeekGestureTests.cs index 2bb7e04..c844964 100644 --- a/SentryDeck.Tests/MainWindowSeekGestureTests.cs +++ b/SentryDeck.Tests/MainWindowSeekGestureTests.cs @@ -1,9 +1,6 @@ -using System.Runtime.ExceptionServices; -using System.Threading; using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Threading; namespace SentryDeck.Tests; @@ -15,7 +12,7 @@ public sealed class MainWindowSeekGestureTests [Fact] public void HookSeekGesture_PressOnTheRail_StartsTheGesture() { - RunOnStaThread(() => + StaThread.Run(() => { var slider = CreateMoveToPointSlider(); var begun = 0; @@ -32,7 +29,7 @@ public void HookSeekGesture_PressOnTheRail_StartsTheGesture() [Fact] public void HookSeekGesture_ReleaseAfterAPressOnTheRail_EndsTheGesture() { - RunOnStaThread(() => + StaThread.Run(() => { var slider = CreateMoveToPointSlider(); var ended = 0; @@ -77,32 +74,4 @@ private static MouseButtonEventArgs RaiseLeftButton(UIElement target, RoutedEven target.RaiseEvent(args); return args; } - - // WPF controls need an STA thread, which xUnit's test threads are not. - private static void RunOnStaThread(Action action) - { - ExceptionDispatchInfo failure = null; - - var thread = new Thread(() => - { - try - { - action(); - } - catch (Exception ex) - { - failure = ExceptionDispatchInfo.Capture(ex); - } - finally - { - Dispatcher.CurrentDispatcher.InvokeShutdown(); - } - }); - - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - failure?.Throw(); - } } diff --git a/SentryDeck/MainWindow.xaml.cs b/SentryDeck/MainWindow.xaml.cs index 09a2587..f38bd02 100644 --- a/SentryDeck/MainWindow.xaml.cs +++ b/SentryDeck/MainWindow.xaml.cs @@ -13,7 +13,7 @@ namespace SentryDeck; /// /// Main WPF window. -/// Owns only view concerns: window lifecycle, Flyleaf host layout, the seek slider input plumbing, and search-box focus. +/// Owns only view concerns: window lifecycle, Flyleaf host layout, the seek slider input plumbing, keeping the selected clip in view, and search-box focus. /// All state, commands, and orchestration live in . /// public partial class MainWindow : Window @@ -63,6 +63,7 @@ public MainWindow() UpdateCameraHostLayout(); HookSeekGesture(SeekSlider, _viewModel.Playback.BeginSeek, _viewModel.Playback.EndSeekAsync); + KeepSelectionInView(ClipListBox); } // Only called once Flyleaf has started. @@ -227,6 +228,33 @@ internal static void HookSeekGesture(Slider slider, Action beginSeek, Func handledEventsToo: true); } + /// + /// Scrolls the list to every newly selected clip, including the ones Next and Previous select without a click on the row. + /// + /// + /// WPF only brings a row into view when the user picks it in the list itself, so a clip opened any other way was selected far off-screen and nothing on screen showed which clip was playing. + /// The scroll waits for the list's next layout because after a search or a rescan the selected row's container may not exist yet. + /// While the keyboard is in the list it moves to the new row too, or the arrow keys would carry on from the previous clip's row. + /// + internal static void KeepSelectionInView(ListBox list) + { + list.SelectionChanged += (_, _) => + list.Dispatcher.BeginInvoke(DispatcherPriority.Background, () => + { + if (list.SelectedItem is not { } item) + { + return; + } + + list.ScrollIntoView(item); + + if (list.IsKeyboardFocusWithin && list.ItemContainerGenerator.ContainerFromItem(item) is ListBoxItem row) + { + row.Focus(); + } + }); + } + // Fires for both thumb-drag and click-then-drag (WPF raises ValueChanged on every Value mutation, whether from dragging the Thumb or from IsMoveToPointEnabled's click-to-position), and also for the one-off value jump a plain click makes. // A drag has already called BeginSeek by the time this fires, so its every move scrub-seeks here. // A press on the rail jumps the value before the gesture starts, so it issues no scrub seek: the release seek lands on it directly. From 89a176cd26e42704a7a6740393679aadd824a017 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:14:39 -0500 Subject: [PATCH 57/73] Let Escape clear the search box and then leave it After Ctrl+F, Escape did nothing in the search box: the query stayed, focus stayed, and every playback shortcut kept typing into the query instead (Space added a space and emptied the list). The only ways back were Tab or the mouse. The window hands every key to the box while it has focus, and a TextBox gives Escape no meaning of its own, so nothing acted on it. The clear-search command had no key or button left since the app's own clear button was replaced by the theme's. Escape in the search box now clears the query, and Escape on an empty box moves the keyboard back to the player so the shortcuts work again (or to the clip list when no clip is open). Found in v1.1.0 local testing: F82. --- SentryDeck.Tests/MainWindowSearchBoxTests.cs | 103 +++++++++++++++++++ SentryDeck/MainWindow.xaml.cs | 38 +++++++ 2 files changed, 141 insertions(+) create mode 100644 SentryDeck.Tests/MainWindowSearchBoxTests.cs diff --git a/SentryDeck.Tests/MainWindowSearchBoxTests.cs b/SentryDeck.Tests/MainWindowSearchBoxTests.cs new file mode 100644 index 0000000..ffd23a3 --- /dev/null +++ b/SentryDeck.Tests/MainWindowSearchBoxTests.cs @@ -0,0 +1,103 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using CommunityToolkit.Mvvm.Input; + +namespace SentryDeck.Tests; + +/// +/// The search box's keyboard handling, exercised on a real TextBox because every key typed there is text unless the box gives it a meaning. +/// +public sealed class MainWindowSearchBoxTests +{ + [Fact] + public void HookSearchEscape_EscapeWithAQuery_ClearsTheQuery() + { + StaThread.Run(() => + { + var searchBox = new TextBox { Text = "honk" }; + var left = 0; + MainWindow.HookSearchEscape(searchBox, new RelayCommand(() => searchBox.Text = string.Empty), () => left++); + + var escape = PressKey(searchBox, Key.Escape); + + escape.Handled.ShouldBeTrue(); + searchBox.Text.ShouldBeEmpty(); + left.ShouldBe(0); + }); + } + + [Fact] + public void HookSearchEscape_EscapeOnAnEmptyBox_LeavesTheBox() + { + StaThread.Run(() => + { + var searchBox = new TextBox(); + var cleared = 0; + var left = 0; + MainWindow.HookSearchEscape(searchBox, new RelayCommand(() => cleared++), () => left++); + + var escape = PressKey(searchBox, Key.Escape); + + escape.Handled.ShouldBeTrue(); + left.ShouldBe(1); + cleared.ShouldBe(0); + }); + } + + [Fact] + public void HookSearchEscape_EscapeAfterClearing_LeavesTheBox() + { + StaThread.Run(() => + { + var searchBox = new TextBox { Text = "honk" }; + var left = 0; + MainWindow.HookSearchEscape(searchBox, new RelayCommand(() => searchBox.Text = string.Empty), () => left++); + + PressKey(searchBox, Key.Escape); + PressKey(searchBox, Key.Escape); + + left.ShouldBe(1); + }); + } + + [Fact] + public void HookSearchEscape_OtherKey_StaysText() + { + StaThread.Run(() => + { + var searchBox = new TextBox { Text = "honk" }; + var cleared = 0; + var left = 0; + MainWindow.HookSearchEscape(searchBox, new RelayCommand(() => cleared++), () => left++); + + var space = PressKey(searchBox, Key.Space); + + space.Handled.ShouldBeFalse(); + cleared.ShouldBe(0); + left.ShouldBe(0); + }); + } + + private static KeyEventArgs PressKey(UIElement target, Key key) + { + var args = new KeyEventArgs(Keyboard.PrimaryDevice, new StubPresentationSource(), Environment.TickCount, key) + { + RoutedEvent = Keyboard.PreviewKeyDownEvent, + }; + + target.RaiseEvent(args); + return args; + } + + // A key event must name the window it came from, and these tests have none. + private sealed class StubPresentationSource : PresentationSource + { + public override Visual RootVisual { get; set; } + + public override bool IsDisposed => false; + + protected override CompositionTarget GetCompositionTargetCore() => null; + } +} diff --git a/SentryDeck/MainWindow.xaml.cs b/SentryDeck/MainWindow.xaml.cs index f38bd02..b2c0508 100644 --- a/SentryDeck/MainWindow.xaml.cs +++ b/SentryDeck/MainWindow.xaml.cs @@ -64,6 +64,7 @@ public MainWindow() HookSeekGesture(SeekSlider, _viewModel.Playback.BeginSeek, _viewModel.Playback.EndSeekAsync); KeepSelectionInView(ClipListBox); + HookSearchEscape(SearchBox, _viewModel.Library.ClearFilterCommand, LeaveSearchBox); } // Only called once Flyleaf has started. @@ -279,6 +280,43 @@ private void OnSearchBoxFocusRequested(object sender, EventArgs e) SearchBox.SelectAll(); } + /// + /// Gives Escape in the search box its usual meaning: it clears the query, and on an empty box it leaves the box. + /// + /// + /// Every other key is text while the box has focus, so without this a keyboard user who pressed Ctrl+F had no way back to the playback shortcuts short of Tab or the mouse, and Space typed into the query instead of playing. + /// + internal static void HookSearchEscape(TextBox searchBox, ICommand clearFilter, Action leaveSearch) + { + searchBox.PreviewKeyDown += (_, e) => + { + if (e.Key != Key.Escape) + { + return; + } + + if (string.IsNullOrEmpty(searchBox.Text)) + { + leaveSearch(); + } + else + { + clearFilter.Execute(null); + } + + e.Handled = true; + }; + } + + // The player is where the shortcuts work; with no clip open it isn't shown, so the clip list is the next best place for the keyboard. + private void LeaveSearchBox() + { + if (!VideoContainer.Focus()) + { + ClipListBox.Focus(); + } + } + // The FlyleafHost creates its native Surface window when it loads (and reuses it across reparenting), so subscribe once it exists. handledEventsToo ensures we still see the click if Flyleaf marks it handled, and the HashSet guards against re-subscribing when Loaded fires again on a reparent. private void HookCameraClick(FlyleafHost host, string cameraView) { From 46fe965e84f0ff7c8456e9aff4c29a2155ed46d0 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 5 Oct 2026 23:17:26 -0500 Subject: [PATCH 58/73] Give the search box's clear button a name screen readers can read Screen-reader users who reached the x button in the search box heard only "button", with nothing saying it clears the search. The button comes from WPF's Fluent theme, whose template gives it no automation name, and the app relied on it after replacing its own named clear button. The window now names the button "Clear search" and gives it a tooltip naming the Escape shortcut. It does so whenever the box gets keyboard focus, which is the only time the theme shows the button, so a template the theme swaps in later is covered too. Found in v1.1.0 local testing: F87. --- SentryDeck.Tests/MainWindowSearchBoxTests.cs | 38 ++++++++++++++++++++ SentryDeck/MainWindow.xaml.cs | 21 +++++++++++ 2 files changed, 59 insertions(+) diff --git a/SentryDeck.Tests/MainWindowSearchBoxTests.cs b/SentryDeck.Tests/MainWindowSearchBoxTests.cs index ffd23a3..0dd22cc 100644 --- a/SentryDeck.Tests/MainWindowSearchBoxTests.cs +++ b/SentryDeck.Tests/MainWindowSearchBoxTests.cs @@ -1,4 +1,5 @@ using System.Windows; +using System.Windows.Automation; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; @@ -80,6 +81,25 @@ public void HookSearchEscape_OtherKey_StaysText() }); } + [Fact] + public void NameSearchClearButton_BoxFocused_NamesTheThemesClearButton() + { + StaThread.Run(() => + { + var searchBox = CreateFluentTextBox(); + MainWindow.NameSearchClearButton(searchBox); + + searchBox.RaiseEvent(new KeyboardFocusChangedEventArgs(Keyboard.PrimaryDevice, Environment.TickCount, null, searchBox) + { + RoutedEvent = Keyboard.GotKeyboardFocusEvent, + }); + + // Finding the part by name also catches a theme update that renames it, which would silently undo the fix. + var clearButton = searchBox.Template.FindName("DeleteButton", searchBox).ShouldBeOfType - - + - + + Value="{Binding Playback.SeekPosition, Mode=TwoWay}"> + + + + + + +