diff --git a/.github/actions/full-build/action.yml b/.github/actions/full-build/action.yml index 0a1426f..ec6efba 100644 --- a/.github/actions/full-build/action.yml +++ b/.github/actions/full-build/action.yml @@ -43,11 +43,14 @@ runs: path: TestResults/** if-no-files-found: ignore + # A single-file publish leaves WPF's native libraries as loose files unless IncludeNativeLibrariesForSelfExtract bundles them. + # The exe can't start without them: copied out of the zip alone, or run from inside it, it crashed before it could show or log anything. + # Bundled, they unpack to a cache under %TEMP% on first launch, and the zip and the MSI ship the exe alone. - name: Create Binaries shell: bash run: | for arch in ${{ inputs.archs }}; do - dotnet publish ./SentryDeck/SentryDeck.csproj -o "publish/$arch" -c Release -f net10.0-windows --os win --arch $arch --self-contained -p:PublishSingleFile=true -p:DebugType=embedded -p:Version=${{ inputs.version }} + dotnet publish ./SentryDeck/SentryDeck.csproj -o "publish/$arch" -c Release -f net10.0-windows --os win --arch $arch --self-contained -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=embedded -p:Version=${{ inputs.version }} done - name: Create Portable ZIPs 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/CamClip.cs b/SentryDeck.Data/CamClip.cs index cd87aca..fc2fb2b 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; @@ -39,6 +40,18 @@ 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; } + + /// + /// 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); @@ -47,6 +60,8 @@ public CamClip(string path, string name, DateTime timestamp, IEnumerable @@ -83,9 +98,70 @@ 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; + } + + /// + /// 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. /// @@ -119,55 +195,72 @@ 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 (e.g. System Volume Information at a drive root) is skipped rather than aborting the entire scan. - var options = new EnumerationOptions + while (pending.TryPop(out var directory)) { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - }; + yield return directory; - IEnumerator directories; - try - { - directories = Directory.EnumerateDirectories(rootDirectory, "*", options).GetEnumerator(); - } - catch (Exception ex) - { - Log.Warning(ex, "Could not enumerate clip folders. Root={Root}", rootDirectory); - yield break; - } + List subfolders; + try + { + 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. + /// + 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.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.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.Data/Playback/ClipExporter.cs b/SentryDeck.Data/Playback/ClipExporter.cs index e281579..59b9692 100644 --- a/SentryDeck.Data/Playback/ClipExporter.cs +++ b/SentryDeck.Data/Playback/ClipExporter.cs @@ -40,6 +40,14 @@ public sealed class ClipExporter( private static readonly string ExportScriptDirectory = Path.Combine(Path.GetTempPath(), "SentryDeck", "exports"); + // A killed process exits within milliseconds; the cap only keeps a wedged one from holding up the app's exit. + private static readonly TimeSpan KillTimeout = TimeSpan.FromSeconds(5); + + // Antivirus and the search indexer often open a file the moment its writer closes it, so a delete right after FFmpeg exits can hit a sharing violation for a short while. + // A few short waits let them finish instead of leaving a partial export or a temporary script behind. + private const int DeleteAttempts = 10; + private static readonly TimeSpan DeleteRetryDelay = TimeSpan.FromMilliseconds(100); + public async Task ExportAsync(ClipExportRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -75,12 +83,12 @@ public async Task ExportAsync(ClipExportRequest request, CancellationToken cance catch { // Don't leave a broken half-written file where the user asked to save. - TryDelete(request.OutputPath); + await TryDeleteAsync(request.OutputPath); throw; } finally { - TryDelete(scriptPath); + await TryDeleteAsync(scriptPath); } } @@ -175,7 +183,8 @@ internal static string BuildArguments(string scriptPath, string outputPath) return $"-hide_banner -loglevel error -y -f concat -safe 0 -i \"{scriptPath}\" -c copy -movflags +faststart \"{outputPath}\""; } - private static async Task RunFfmpegAsync(string ffmpegPath, string arguments, CancellationToken cancellationToken) + /// Runs FFmpeg to completion, or kills it and waits for it to exit when canceled. + internal static async Task RunFfmpegAsync(string ffmpegPath, string arguments, CancellationToken cancellationToken) { using var process = new Process(); process.StartInfo = new ProcessStartInfo(ffmpegPath, arguments) @@ -207,6 +216,19 @@ private static async Task RunFfmpegAsync(string ffmpegPath, string arguments, Ca // Already exited between the cancellation and the kill. } + // Kill only starts the termination, and FFmpeg keeps the output and the script open until it is gone. + // Deleting them straight away would fail, leaving an unfinished export where the user asked to save. + // Awaited rather than blocking, because the export runs from the UI thread and a process with a write in flight to a slow drive can take a moment to exit. + using var killWait = new CancellationTokenSource(KillTimeout); + try + { + await process.WaitForExitAsync(killWait.Token); + } + catch (OperationCanceledException) + { + // Still running after the cap; the cleanup logs whatever it then can't delete. + } + throw; } @@ -217,18 +239,29 @@ private static async Task RunFfmpegAsync(string ffmpegPath, string arguments, Ca } } - private static void TryDelete(string path) + // Awaited rather than sleeping, because the export runs from the UI thread. + private static async Task TryDeleteAsync(string path) { - try + for (var attempt = 1; ; attempt++) { - if (File.Exists(path)) + try { - File.Delete(path); + if (File.Exists(path)) + { + File.Delete(path); + } + + return; + } + catch (IOException) when (attempt < DeleteAttempts) + { + await Task.Delay(DeleteRetryDelay); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning(ex, "Failed to delete export temp/partial file. Path={Path}", path); + return; } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - Log.Warning(ex, "Failed to delete export temp/partial file. Path={Path}", path); } } } diff --git a/SentryDeck.Data/Playback/ClipMediaSource.cs b/SentryDeck.Data/Playback/ClipMediaSource.cs index d22e6f6..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). @@ -57,7 +69,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 +111,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.Data/Playback/ClipPlaylist.cs b/SentryDeck.Data/Playback/ClipPlaylist.cs index ea6975c..805bc54 100644 --- a/SentryDeck.Data/Playback/ClipPlaylist.cs +++ b/SentryDeck.Data/Playback/ClipPlaylist.cs @@ -7,18 +7,33 @@ 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; + // 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. + // 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); - public bool HasNext => _currentIndex < _clips.Count - 1; + private int PreviousIndex => FindNavigableIndex(_removedCurrentIndex >= 0 ? _removedCurrentIndex - 1 : _currentIndex - 1, step: -1); public event EventHandler CurrentClipChanged; public event EventHandler PlaylistChanged; @@ -28,6 +43,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 +52,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 +73,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 +101,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); @@ -90,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.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.Data/Playback/FfconcatMediaSourceBuilder.cs b/SentryDeck.Data/Playback/FfconcatMediaSourceBuilder.cs index 0251771..3bb3a6e 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. @@ -59,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. @@ -111,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 @@ -122,14 +131,9 @@ 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); } - /// - /// 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 +144,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 +158,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) @@ -186,6 +189,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.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/AboutViewModelTests.cs b/SentryDeck.Tests/AboutViewModelTests.cs new file mode 100644 index 0000000..3ffe5f1 --- /dev/null +++ b/SentryDeck.Tests/AboutViewModelTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Http; + +namespace SentryDeck.Tests; + +public sealed class AboutViewModelTests +{ + [Fact] + public async Task CheckForUpdatesAsync_BeforeGitHubAnswers_SaysChecking() + { + var reply = new TaskCompletionSource(); + var about = new AboutViewModel(new UpdateService(new HttpClient(new StubHandler(_ => reply.Task)))); + + var check = about.CheckForUpdatesAsync(); + + about.UpdateCheckState.ShouldBe(UpdateCheckState.Checking); + about.UpdateStatusTitle.ShouldBe("Checking for updates…"); + about.UpdateStatusDetails.ShouldBe("Looking for a newer release on GitHub."); + + reply.SetResult(Releases("v999.0.0")); + await check; + + about.UpdateStatusTitle.ShouldBe("Update available"); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] // GitHub's unauthenticated rate limit + [InlineData(HttpStatusCode.InternalServerError)] + public async Task CheckForUpdatesAsync_WhenGitHubRefuses_SaysTheCheckFailed(HttpStatusCode statusCode) + { + var about = new AboutViewModel(new UpdateService(new HttpClient(new StubHandler(_ => Task.FromResult(new HttpResponseMessage(statusCode)))))); + + await about.CheckForUpdatesAsync(); + + about.UpdateCheckState.ShouldBe(UpdateCheckState.Failed); + about.HasUpdateBadge.ShouldBeFalse(); + about.UpdateStatusTitle.ShouldBe("Couldn't check for updates"); + about.UpdateStatusDetails.ShouldBe("The latest release couldn't be read from GitHub."); + about.LatestReleaseUrl.ShouldBe(UpdateService.ReleasesPageUrl); + } + + [Fact] + public async Task CheckForUpdatesAsync_WhenOffline_SaysTheCheckFailed() + { + var about = new AboutViewModel(new UpdateService(new HttpClient(new StubHandler(_ => throw new HttpRequestException("No connection could be made."))))); + + await about.CheckForUpdatesAsync(); + + about.UpdateCheckState.ShouldBe(UpdateCheckState.Failed); + about.UpdateStatusTitle.ShouldBe("Couldn't check for updates"); + } + + [Fact] + public async Task CheckForUpdatesAsync_WhenNoNewerReleaseIsOut_SaysUpToDate() + { + var about = new AboutViewModel(new UpdateService(new HttpClient(new StubHandler(_ => Task.FromResult(Releases("v0.0.0")))))); + + await about.CheckForUpdatesAsync(); + + about.UpdateCheckState.ShouldBe(UpdateCheckState.Checked); + about.HasUpdateBadge.ShouldBeFalse(); + about.UpdateStatusTitle.ShouldBe("You're up to date"); + about.UpdateStatusDetails.ShouldBe("No newer release was found."); + } + + [Fact] + public async Task CheckForUpdatesAsync_WhenANewerReleaseIsOut_SaysUpdateAvailable() + { + var about = new AboutViewModel(new UpdateService(new HttpClient(new StubHandler(_ => Task.FromResult(Releases("v999.0.0")))))); + + await about.CheckForUpdatesAsync(); + + about.UpdateCheckState.ShouldBe(UpdateCheckState.Checked); + about.HasUpdateBadge.ShouldBeTrue(); + about.UpdateStatusTitle.ShouldBe("Update available"); + about.UpdateStatusDetails.ShouldBe("Version 999.0.0 is available."); + about.LatestReleaseUrl.ShouldBe("https://example.test/latest"); + } + + [Fact] + public void FileVersion_OfAThreePartBuildVersion_MatchesTheReleaseTag() + { + // The build sets to the three-part release version, but the assembly version always carries a fourth part. + var about = new AboutViewModel(); + + about.FileVersion.ShouldBe(about.CurrentVersion.ToString(3)); + } + + [Theory] + [InlineData("1.1.0.0", "1.1.0")] + [InlineData("1.1.0", "1.1.0")] + [InlineData("1.1.0.3", "1.1.0.3")] + [InlineData("1.2", "1.2")] + public void FormatVersion_FourthPart_ShownOnlyWhenNonZero(string version, string expected) + { + AboutViewModel.FormatVersion(Version.Parse(version)).ShouldBe(expected); + } + + private static HttpResponseMessage Releases(string tagName) => new(HttpStatusCode.OK) + { + Content = new StringContent($$""" + [ + { + "tag_name": "{{tagName}}", + "name": "Latest", + "html_url": "https://example.test/latest", + "draft": false + } + ] + """), + }; + + private sealed class StubHandler(Func> reply) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => reply(request); + } +} diff --git a/SentryDeck.Tests/AppLoggingTests.cs b/SentryDeck.Tests/AppLoggingTests.cs new file mode 100644 index 0000000..ae8118d --- /dev/null +++ b/SentryDeck.Tests/AppLoggingTests.cs @@ -0,0 +1,45 @@ +using System.IO; + +namespace SentryDeck.Tests; + +public sealed class AppLoggingTests +{ + [Fact] + public void CreateLogger_WhileAnotherInstanceHasTodaysLogOpen_AppendsToTheSameFile() + { + // Each instance that opened a numbered copy of today's file pushed an earlier day's log out of the seven kept. + using var logs = new TempDirectory(); + var logPath = Path.Combine(logs.Path, "log-.txt"); + + using (var first = App.CreateLogger(logPath)) + using (var second = App.CreateLogger(logPath)) + { + first.Information("From the first instance"); + second.Information("From the second instance"); + } + + var file = Directory.GetFiles(logs.Path).ShouldHaveSingleItem(); + var text = File.ReadAllText(file); + text.ShouldContain("From the first instance"); + text.ShouldContain("From the second instance"); + } + + [Fact] + public void CreateLogger_WritingToTheSharedFile_TagsEachLineWithTheProcessId() + { + // Several instances append to one file, so a line that doesn't name its process can't be traced to the window that wrote it. + using var logs = new TempDirectory(); + var logPath = Path.Combine(logs.Path, "log-.txt"); + + using (var logger = App.CreateLogger(logPath)) + { + logger.Information("First line"); + logger.Warning("Second line"); + } + + var lines = File.ReadAllLines(Directory.GetFiles(logs.Path).ShouldHaveSingleItem()); + lines.Length.ShouldBe(2); + lines[0].ShouldMatch($@"^\d{{4}}-\d{{2}}-\d{{2}} \d{{2}}:\d{{2}}:\d{{2}}\.\d{{3}} [+-]\d{{2}}:\d{{2}} \[INF\] \[{Environment.ProcessId}\] First line$"); + lines[1].ShouldEndWith($"[WRN] [{Environment.ProcessId}] Second line"); + } +} diff --git a/SentryDeck.Tests/BoolToTextConverterTests.cs b/SentryDeck.Tests/BoolToTextConverterTests.cs new file mode 100644 index 0000000..96f0075 --- /dev/null +++ b/SentryDeck.Tests/BoolToTextConverterTests.cs @@ -0,0 +1,39 @@ +using System.Windows; + +namespace SentryDeck.Tests; + +/// +/// The text a two-way control such as Play/Pause shows screen readers, which named both actions at once before. +/// +public sealed class BoolToTextConverterTests +{ + [Theory] + [InlineData(true, "Pause")] + [InlineData(false, "Play")] + public void Convert_PlayPauseState_NamesWhatThePressWillDo(bool isPlaying, string expected) + { + var name = new BoolToTextConverter().Convert(isPlaying, typeof(string), "Pause|Play", null); + + name.ShouldBe(expected); + } + + [Fact] + public void Convert_UnresolvedBinding_ReadsAsTheFalseText() + { + // Before the view-model is bound the button is not playing anything, so it offers Play. + var name = new BoolToTextConverter().Convert(DependencyProperty.UnsetValue, typeof(string), "Pause|Play", null); + + name.ShouldBe("Play"); + } + + [Theory] + [InlineData(null)] + [InlineData("Pause")] + [InlineData("Pause|Play|Stop")] + public void Convert_MalformedParameter_ReadsNothing(string parameter) + { + var name = new BoolToTextConverter().Convert(true, typeof(string), parameter, null); + + name.ShouldBe(string.Empty); + } +} 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/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 b2c5ae1..5e6d50b 100644 --- a/SentryDeck.Tests/CamDiscoveryResilienceTests.cs +++ b/SentryDeck.Tests/CamDiscoveryResilienceTests.cs @@ -60,6 +60,104 @@ 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 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() { @@ -77,6 +175,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() { diff --git a/SentryDeck.Tests/CamStorageTests.cs b/SentryDeck.Tests/CamStorageTests.cs index f3d5ae2..ceab238 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 @@ -29,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() { @@ -59,6 +76,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/CameraTileButtonTests.cs b/SentryDeck.Tests/CameraTileButtonTests.cs new file mode 100644 index 0000000..6f36ac7 --- /dev/null +++ b/SentryDeck.Tests/CameraTileButtonTests.cs @@ -0,0 +1,73 @@ +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Automation.Provider; +using System.Windows.Input; +using CommunityToolkit.Mvvm.Input; + +namespace SentryDeck.Tests; + +/// +/// The camera tiles must switch the camera however they are chosen, because only a click ran their command before. +/// +public sealed class CameraTileButtonTests +{ + [Fact] + public void Select_ThroughUiAutomation_RunsTheCommand() + { + StaThread.Run(() => + { + string selectedView = null; + var tile = new CameraTileButton + { + Command = new RelayCommand(view => selectedView = view), + CommandParameter = CameraNames.Back, + }; + + var selectionItem = (ISelectionItemProvider)UIElementAutomationPeer.CreatePeerForElement(tile).GetPattern(PatternInterface.SelectionItem); + selectionItem.Select(); + + selectedView.ShouldBe(CameraNames.Back); + tile.IsChecked.ShouldBe(true); + selectionItem.IsSelected.ShouldBeTrue(); + }); + } + + [Fact] + public void Select_ThroughUiAutomationWhileDisabled_Refuses() + { + StaThread.Run(() => + { + var ran = false; + var tile = new CameraTileButton + { + Command = new RelayCommand(() => ran = true), + IsEnabled = false, + }; + + var selectionItem = (ISelectionItemProvider)UIElementAutomationPeer.CreatePeerForElement(tile).GetPattern(PatternInterface.SelectionItem); + + Should.Throw(selectionItem.Select); + ran.ShouldBeFalse(); + }); + } + + [Fact] + public void Enter_OnTheFocusedTile_RunsTheCommand() + { + StaThread.Run(() => + { + string selectedView = null; + var tile = new CameraTileButton + { + Command = new RelayCommand(view => selectedView = view), + CommandParameter = CameraNames.LeftRepeater, + }; + + var enter = KeyInput.Raise(tile, Key.Enter, Keyboard.KeyDownEvent); + + enter.Handled.ShouldBeTrue(); + selectedView.ShouldBe(CameraNames.LeftRepeater); + tile.IsChecked.ShouldBe(true); + }); + } +} diff --git a/SentryDeck.Tests/ClipAutomationNameConverterTests.cs b/SentryDeck.Tests/ClipAutomationNameConverterTests.cs new file mode 100644 index 0000000..3e16bf3 --- /dev/null +++ b/SentryDeck.Tests/ClipAutomationNameConverterTests.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Windows; + +namespace SentryDeck.Tests; + +/// +/// What screen readers announce for a clip in the list, which was only its raw timestamp before. +/// +public sealed class ClipAutomationNameConverterTests +{ + // A Saturday long past, so the day can't read as "Today" or "Yesterday". + private static readonly DateTime Moment = new(2023, 12, 16, 15, 53, 27); + + private static readonly CultureInfo English = new("en-US"); + + // The culture decides the time's spacing (newer ICU puts a narrow no-break space before PM), so the expectation formats it the same way. + private static readonly string Time = Moment.ToString("t", English); + + [Fact] + public void Convert_EveryPart_ReadsWhenWhyWhereAndHowLong() + { + var name = ConvertInEnglish(Moment, "Honk", "Hutto", "~5 min"); + + name.ShouldBe($"Saturday, December 16, 2023, {Time}, Honk, Hutto, about 5 min"); + } + + [Fact] + public void Convert_NoCityAndNoFootage_LeavesThemOutWithoutStrayPauses() + { + var name = ConvertInEnglish(Moment, "Recent", null, "—"); + + name.ShouldBe($"Saturday, December 16, 2023, {Time}, Recent"); + } + + [Fact] + public void Convert_LengthOverAnHour_ReadsAsAnEstimate() + { + var name = ConvertInEnglish(Moment, "Sentry", " ", "~1h 35m"); + + name.ShouldBe($"Saturday, December 16, 2023, {Time}, Sentry, about 1h 35m"); + } + + [Fact] + public void Convert_ClipShorterThanAMinute_ReadsAsUnderAMinute() + { + var name = ConvertInEnglish(Moment, "Saved", null, "<1 min"); + + name.ShouldBe($"Saturday, December 16, 2023, {Time}, Saved, under 1 min"); + } + + [Fact] + public void Convert_UnresolvedBindings_StillNamesTheDayAndTime() + { + // While a row is being realized its bindings can still be unset, and the name must neither throw nor read the placeholder. + var unset = DependencyProperty.UnsetValue; + + var name = ConvertInEnglish(Moment, unset, unset, unset); + + name.ShouldBe($"Saturday, December 16, 2023, {Time}"); + } + + // The list formats dates in the user's culture, so the test pins one. + private static string ConvertInEnglish(params object[] values) + { + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = English; + try + { + return (string)new ClipAutomationNameConverter().Convert(values, typeof(string), null, English); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } +} 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/ClipExporterTests.cs b/SentryDeck.Tests/ClipExporterTests.cs index 63ee57b..2828f35 100644 --- a/SentryDeck.Tests/ClipExporterTests.cs +++ b/SentryDeck.Tests/ClipExporterTests.cs @@ -211,4 +211,90 @@ await Should.ThrowAsync(() => exporter.ExportAsync( File.Exists(outputPath).ShouldBeFalse(); File.Exists(scriptPath).ShouldBeFalse(); } + + [Fact] + public async Task ExportAsync_Canceled_DeletesPartialOutputAndScript() + { + // Closing the app cancels a running export, and nothing the export wrote may outlive it. + var fixture = CreateClip(); + using var output = new TempFile(); + var outputPath = output.Path; + string scriptPath = null; + using var cancellation = new CancellationTokenSource(); + + var exporter = new ClipExporter( + () => @"C:\ffmpeg", + async (_, arguments, cancellationToken) => + { + scriptPath = ScriptPathFromArguments(arguments); + await File.WriteAllTextAsync(outputPath, "half-written", CancellationToken.None); + cancellation.Cancel(); + await Task.Delay(Timeout.Infinite, cancellationToken); + }); + + await Should.ThrowAsync(() => exporter.ExportAsync( + Request(fixture, CameraNames.Front, TimeSpan.Zero, TimeSpan.FromSeconds(60), outputPath), + cancellation.Token)); + + File.Exists(outputPath).ShouldBeFalse(); + File.Exists(scriptPath).ShouldBeFalse(); + } + + [Fact] + public async Task RunFfmpegAsync_Canceled_StopsTheProcessSoItsFilesCanBeDeleted() + { + // Closing the app cancels a running export, and the cleanup right after must be able to delete the files the process was writing. + // cmd.exe spinning in a built-in loop stands in for FFmpeg: it holds its redirected output open without starting a child process. + using var held = new TempFile(extension: ".txt"); + using var cancellation = new CancellationTokenSource(); + var cmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + + var run = ClipExporter.RunFfmpegAsync(cmd, $"/d /c \"(for /l %i in (0,0,1) do @type nul) > \"{held.Path}\"\"", cancellation.Token); + await Wait.UntilAsync(() => File.Exists(held.Path)); + cancellation.Cancel(); + + await Should.ThrowAsync(run); + + // A virus scanner may open the file for a moment once its writer exits, so the delete is retried; a process that kept running would hold the file until the wait gives up. + await Wait.UntilAsync(() => TryDelete(held.Path)); + } + + [Fact] + public async Task ExportAsync_PartialOutputBrieflyHeldOpen_StillDeletesIt() + { + // Antivirus opens a file the moment FFmpeg closes it, so the first delete can fail even though the file is free a moment later. + var fixture = CreateClip(); + using var output = new TempFile(); + var outputPath = output.Path; + FileStream scanner = null; + + var exporter = new ClipExporter( + () => @"C:\ffmpeg", + (_, _, _) => + { + File.WriteAllText(outputPath, "half-written"); + scanner = new FileStream(outputPath, FileMode.Open, FileAccess.Read, FileShare.Read); + _ = Task.Delay(100).ContinueWith(_ => scanner.Dispose(), TaskScheduler.Default); + throw new InvalidOperationException("FFmpeg exited with code 1."); + }); + + await Should.ThrowAsync(() => exporter.ExportAsync( + Request(fixture, CameraNames.Front, TimeSpan.Zero, TimeSpan.FromSeconds(60), outputPath))); + + File.Exists(outputPath).ShouldBeFalse(); + } + + // True once the file is deleted (or already gone); false while another process still holds it open. + private static bool TryDelete(string path) + { + try + { + File.Delete(path); + return true; + } + catch (IOException) + { + return false; + } + } } 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/ClipPlaylistTests.cs b/SentryDeck.Tests/ClipPlaylistTests.cs index cf03763..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() { @@ -147,6 +235,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/CommandToggleButtonTests.cs b/SentryDeck.Tests/CommandToggleButtonTests.cs new file mode 100644 index 0000000..c8efefd --- /dev/null +++ b/SentryDeck.Tests/CommandToggleButtonTests.cs @@ -0,0 +1,81 @@ +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Automation.Provider; +using System.Windows.Data; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace SentryDeck.Tests; + +/// +/// The Trim button reports whether its panel is open, so it must still open the panel however it is pressed, as the plain button it replaced did. +/// +public sealed class CommandToggleButtonTests +{ + [Fact] + public void Toggle_ThroughUiAutomation_RunsTheCommandAndReportsTheNewState() + { + StaThread.Run(() => + { + var (button, panel) = CreateTrimButton(); + + var toggle = (IToggleProvider)UIElementAutomationPeer.CreatePeerForElement(button).GetPattern(PatternInterface.Toggle); + toggle.Toggle(); + + panel.IsOpen.ShouldBeTrue(); + toggle.ToggleState.ShouldBe(ToggleState.On); + }); + } + + [Fact] + public void ToggleState_PanelClosedElsewhere_ReportsOff() + { + StaThread.Run(() => + { + var (button, panel) = CreateTrimButton(); + var toggle = (IToggleProvider)UIElementAutomationPeer.CreatePeerForElement(button).GetPattern(PatternInterface.Toggle); + toggle.Toggle(); + + // Esc and the panel's own Cancel button close it without touching this button. + panel.IsOpen = false; + + toggle.ToggleState.ShouldBe(ToggleState.Off); + }); + } + + [Fact] + public void Enter_OnTheFocusedButton_RunsTheCommand() + { + StaThread.Run(() => + { + // The plain button that Trim used to be opened the panel on Enter, and the toggle must not lose that. + var (button, panel) = CreateTrimButton(); + + var enter = KeyInput.Raise(button, Key.Enter, Keyboard.KeyDownEvent); + + enter.Handled.ShouldBeTrue(); + panel.IsOpen.ShouldBeTrue(); + }); + } + + // Wired the way the Trim button is: the command switches the state, and the checked state only follows it. + private static (CommandToggleButton Button, TrimPanel Panel) CreateTrimButton() + { + var panel = new TrimPanel(); + var button = new CommandToggleButton { Command = new RelayCommand(() => panel.IsOpen = !panel.IsOpen) }; + button.SetBinding(System.Windows.Controls.Primitives.ToggleButton.IsCheckedProperty, new Binding(nameof(TrimPanel.IsOpen)) { Source = panel, Mode = BindingMode.OneWay }); + return (button, panel); + } + + private sealed class TrimPanel : ObservableObject + { + private bool _isOpen; + + public bool IsOpen + { + get => _isOpen; + set => SetProperty(ref _isOpen, value); + } + } +} diff --git a/SentryDeck.Tests/ConverterTests.cs b/SentryDeck.Tests/ConverterTests.cs index fab640b..32e814d 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() { @@ -207,6 +231,15 @@ public void ThumbnailConverter_UndecodableThumbnail_YieldsNoImage() new ThumbnailConverter().Convert(thumbnail.Path, typeof(ImageSource), null, null).ShouldBeNull(); } + [Fact] + public void ThumbnailConverter_ZeroByteThumbnail_YieldsNoImage() + { + // The card's missing-thumbnail glyph follows this null image, so an empty file must not decode to an empty bitmap that hides the glyph. + using var thumbnail = new TempFile([], ".png"); + + new ThumbnailConverter().Convert(thumbnail.Path, typeof(ImageSource), null, null).ShouldBeNull(); + } + [Fact] public void ThumbnailConverter_ValidThumbnail_YieldsADecodedImageAndReleasesTheFile() { @@ -221,25 +254,46 @@ public void ThumbnailConverter_ValidThumbnail_YieldsADecodedImageAndReleasesTheF } [Fact] - public void ThumbnailConverter_FallbackParameter_IsVisibleOnlyWhenTheFileIsMissing() + public void EventConverters_NonEventValues_FallBackToTheNoEventDefaults() { - // The placeholder behind the image is driven purely by the file's presence. - var converter = new ThumbnailConverter(); + // These bind against clip rows that may carry no event.json at all. + new ReasonLabelConverter().Convert(null, typeof(string), null, null).ShouldBe("Recent"); + new ReasonKeyConverter().Convert(null, typeof(string), null, null).ShouldBe(ClipDisplay.ReasonRecent); + new CityVisibilityConverter().Convert(null, typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed); + new CityVisibilityConverter().Convert("not an event", typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed); + } - converter.Convert(MissingThumbnailPath(), typeof(Visibility), "fallback", null).ShouldBe(Visibility.Visible); - converter.Convert(null, typeof(Visibility), "fallback", null).ShouldBe(Visibility.Visible); + [Theory] + [InlineData(0, 0)] + [InlineData(30.5, -97.5)] + public void CityVisibilityConverter_EventWithACity_ShowsTheLocationLineWithOrWithoutCoordinates(double lat, double lon) + { + // Tesla can write a city with blank or 0,0 coordinates, and search still finds the clip by that city, so the card has to show it. + var camEvent = new CamEvent { City = "Hutto", EstLat = (decimal)lat, EstLon = (decimal)lon }; - using var thumbnail = new TempFile("not a png"u8.ToArray(), ".png"); - converter.Convert(thumbnail.Path, typeof(Visibility), "fallback", null).ShouldBe(Visibility.Collapsed); + new CityVisibilityConverter().Convert(camEvent, typeof(Visibility), null, null).ShouldBe(Visibility.Visible); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CityVisibilityConverter_EventWithoutACity_HidesTheLocationLineEvenWithCoordinates(string city) + { + // Coordinates alone would leave a lone pin with nothing beside it. + var camEvent = new CamEvent { City = city, EstLat = 30.5m, EstLon = -97.5m }; + + new CityVisibilityConverter().Convert(camEvent, typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed); } [Fact] - public void EventConverters_NonEventValues_FallBackToTheNoEventDefaults() + public void ReasonConverters_SentryClipWithoutEventJson_UseItsFolder() { - // These bind against clip rows that may carry no event.json at all. - new ReasonLabelConverter().Convert(null, typeof(string), null, null).ShouldBe("Recent"); - new ReasonKeyConverter().Convert(null, typeof(string), null, null).ShouldBe(ClipDisplay.ReasonRecent); - new MapAvailabilityConverter().Convert("not an event", typeof(Visibility), null, null).ShouldBe(Visibility.Collapsed); + // 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) diff --git a/SentryDeck.Tests/DialogOwnerTests.cs b/SentryDeck.Tests/DialogOwnerTests.cs new file mode 100644 index 0000000..af595e6 --- /dev/null +++ b/SentryDeck.Tests/DialogOwnerTests.cs @@ -0,0 +1,37 @@ +using System.Windows; +using System.Windows.Interop; + +namespace SentryDeck.Tests; + +public sealed class DialogOwnerTests +{ + [Fact] + public void Usable_NoWindow_ReturnsNone() + { + DialogOwner.Usable(null).ShouldBeNull(); + } + + [Fact] + public void Usable_WindowNotOpenedYet_ReturnsNone() + { + StaThread.Run(() => + { + // A file dialog throws when its owner has no window handle yet, where opening without an owner still works. + DialogOwner.Usable(new Window()).ShouldBeNull(); + }); + } + + [Fact] + public void Usable_OpenWindow_ReturnsIt() + { + StaThread.Run(() => + { + var window = new Window(); + new WindowInteropHelper(window).EnsureHandle(); + + DialogOwner.Usable(window).ShouldBeSameAs(window); + + window.Close(); + }); + } +} 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() { diff --git a/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs b/SentryDeck.Tests/FfconcatMediaSourceBuilderTests.cs index 7d8747b..39dd25b 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() { @@ -364,6 +381,40 @@ 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)); + } + + [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] @@ -520,12 +571,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); + + // 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(61); + var instant = clipFiles.Clip.Chunks[1].Timestamp.AddSeconds(90); mediaSource.ToMediaTime(instant).ShouldBeNull(); } 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/FakeClipExporter.cs b/SentryDeck.Tests/Fixtures/FakeClipExporter.cs index 182430a..c2e1ec4 100644 --- a/SentryDeck.Tests/Fixtures/FakeClipExporter.cs +++ b/SentryDeck.Tests/Fixtures/FakeClipExporter.cs @@ -11,6 +11,15 @@ internal sealed class FakeClipExporter : IClipExporter /// When set, ExportAsync throws this instead of recording a success. public Exception ExceptionToThrow { get; set; } + /// + /// When set, ExportAsync keeps running until it is canceled, like FFmpeg still writing a long export. + /// It then completes asynchronously, so leave it off in controller-backed tests. + /// + public bool RunsUntilCanceled { get; set; } + + /// The token the most recent export was given. + public CancellationToken LastCancellationToken { get; private set; } + public Task ExportAsync(ClipExportRequest request, CancellationToken cancellationToken = default) { if (ExceptionToThrow is not null) @@ -19,6 +28,7 @@ public Task ExportAsync(ClipExportRequest request, CancellationToken cancellatio } Requests.Add(request); - return Task.CompletedTask; + LastCancellationToken = cancellationToken; + return RunsUntilCanceled ? Task.Delay(Timeout.Infinite, cancellationToken) : 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/Fixtures/KeyInput.cs b/SentryDeck.Tests/Fixtures/KeyInput.cs new file mode 100644 index 0000000..a76831a --- /dev/null +++ b/SentryDeck.Tests/Fixtures/KeyInput.cs @@ -0,0 +1,32 @@ +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; + +namespace SentryDeck.Tests; + +internal static class KeyInput +{ + /// + /// Raises a key event on the element the way a key press would, and returns it so the test can check whether anything handled it. + /// + public static KeyEventArgs Raise(UIElement target, Key key, RoutedEvent routedEvent) + { + var args = new KeyEventArgs(Keyboard.PrimaryDevice, new StubPresentationSource(), Environment.TickCount, key) + { + RoutedEvent = routedEvent, + }; + + 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.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/ItemsControlAutomationTests.cs b/SentryDeck.Tests/ItemsControlAutomationTests.cs new file mode 100644 index 0000000..5dc5038 --- /dev/null +++ b/SentryDeck.Tests/ItemsControlAutomationTests.cs @@ -0,0 +1,75 @@ +using System.Windows; +using System.Windows.Automation; +using System.Windows.Automation.Peers; +using System.Windows.Controls; +using System.Windows.Markup; + +namespace SentryDeck.Tests; + +/// +/// What screen readers find inside the seek bar's ticks and the camera strip, both of which a plain ItemsControl fills with wrappers named by type name or raw value. +/// +public sealed class ItemsControlAutomationTests +{ + [Fact] + public void DecorativeItemsControl_SeekTicks_LeaveNothingForScreenReaders() + { + StaThread.Run(() => + { + var ticks = new DecorativeItemsControl + { + ItemsSource = new[] { 0.0936324907, 0.5 }, + ItemTemplate = Template(""), + }; + Realize(ticks); + + var peer = UIElementAutomationPeer.CreatePeerForElement(ticks); + + // A plain ItemsControl lists one item per tick here, named "0.0936324907" and "0.5". + (peer.GetChildren() ?? []).ShouldBeEmpty(); + peer.IsContentElement().ShouldBeFalse(); + }); + } + + [Fact] + public void ControlGroupItemsControl_CameraStrip_ExposesTheTilesAsOneNamedGroup() + { + StaThread.Run(() => + { + var strip = new ControlGroupItemsControl + { + ItemsSource = new[] + { + new CameraViewOption(CameraViewsViewModel.GridCameraView, "Grid", 1, isGrid: true), + new CameraViewOption(CameraNames.Back, "Rear", 3), + }, + ItemTemplate = Template(""), + }; + AutomationProperties.SetName(strip, "Camera views"); + Realize(strip); + + var group = UIElementAutomationPeer.CreatePeerForElement(strip); + + group.GetAutomationControlType().ShouldBe(AutomationControlType.Group); + group.GetName().ShouldBe("Camera views"); + var tiles = group.GetChildren().ShouldNotBeNull(); + tiles.Select(tile => tile.GetAutomationControlType()).ShouldAllBe(type => type == AutomationControlType.RadioButton); + tiles.Select(tile => tile.GetName()).ShouldBe(["Grid view", "Rear camera"]); + }); + } + + private static DataTemplate Template(string content) => + (DataTemplate)XamlReader.Parse( + $"{content}"); + + // Items get their containers only from a template and a layout pass, which a window would supply. + // Initializing the control the way XAML does is what gives it its default template outside any window. + private static void Realize(ItemsControl itemsControl) + { + itemsControl.BeginInit(); + itemsControl.EndInit(); + itemsControl.Measure(new Size(400, 100)); + itemsControl.Arrange(new Rect(0, 0, 400, 100)); + StaThread.DrainDispatcher(itemsControl); + } +} diff --git a/SentryDeck.Tests/MainWindowClipListTests.cs b/SentryDeck.Tests/MainWindowClipListTests.cs new file mode 100644 index 0000000..a9c4c96 --- /dev/null +++ b/SentryDeck.Tests/MainWindowClipListTests.cs @@ -0,0 +1,287 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Media; + +namespace SentryDeck.Tests; + +/// +/// The clip list's scrolling and highlight, exercised on a real grouped, virtualizing ListBox because what they guard against is WPF leaving a selection off-screen or unhighlighted. +/// +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(); + }); + } + + [Fact] + public void ShowNewResults_SearchKeepsTheSelectedRow_ScrollsToIt() + { + StaThread.Run(() => + { + var rows = CreateRows(120); + var list = CreateGroupedList(rows); + MainWindow.KeepSelectionInView(list); + list.SelectedItem = rows[60]; + StaThread.DrainDispatcher(list); + + // The search keeps the selected row, so the selection never changes, and the old scroll offset now lands on rows far below it. + Rebind(list, rows.Skip(47)); + MainWindow.ShowNewResults(list); + StaThread.DrainDispatcher(list); + + IsFullyVisible(list, rows[60]).ShouldBeTrue(); + }); + } + + [Fact] + public void ShowNewResults_SearchClearedWithARowSelected_ScrollsToIt() + { + StaThread.Run(() => + { + var rows = CreateRows(120); + var list = CreateGroupedList(rows.Skip(16).Take(10)); + MainWindow.KeepSelectionInView(list); + list.SelectedItem = rows[16]; + StaThread.DrainDispatcher(list); + + // The short search list sat at its top, which in the full list is above the selected row. + Rebind(list, rows); + MainWindow.ShowNewResults(list); + StaThread.DrainDispatcher(list); + + IsFullyVisible(list, rows[16]).ShouldBeTrue(); + }); + } + + [Fact] + public void ShowNewResults_NothingSelected_ShowsTheTopResults() + { + StaThread.Run(() => + { + var rows = CreateRows(120); + var list = CreateGroupedList(rows); + MainWindow.KeepSelectionInView(list); + + // The user scrolled down to browse older days before searching. + Descendants(list).OfType().First().ScrollToEnd(); + StaThread.DrainDispatcher(list); + + var results = rows.Where(row => row.Index % 2 == 0).ToList(); + Rebind(list, results); + MainWindow.ShowNewResults(list); + StaThread.DrainDispatcher(list); + + Descendants(list).OfType().First().VerticalOffset.ShouldBe(0); + IsFullyVisible(list, results[0]).ShouldBeTrue(); + }); + } + + [Fact] + public void ShowNewResults_AnotherFolderLoaded_ShowsItsNewestClip() + { + StaThread.Run(() => + { + var list = CreateGroupedList(CreateRows(120)); + MainWindow.KeepSelectionInView(list); + + // The user scrolled down the first folder's clips before picking another folder. + Descendants(list).OfType().First().ScrollToEnd(); + StaThread.DrainDispatcher(list); + + // A quick scan empties the list and fills it with the other folder's clips before the list lays out either. + var otherFolder = CreateRows(60); + Rebind(list, []); + Rebind(list, otherFolder); + MainWindow.ShowNewResults(list); + StaThread.DrainDispatcher(list); + + Descendants(list).OfType().First().VerticalOffset.ShouldBe(0); + IsFullyVisible(list, otherFolder[0]).ShouldBeTrue(); + }); + } + + [Fact] + public void HighlightRowWhenDeselectIsTurnedDown_DeselectTheSourceRefuses_HighlightsTheRowAgain() + { + StaThread.Run(() => + { + var rows = CreateRows(20); + var list = CreateGroupedList(rows); + var selection = new SelectionSource { RefusesDeselect = true }; + BindSelection(list, selection); + MainWindow.HighlightRowWhenDeselectIsTurnedDown(list); + list.SelectedItem = rows[2]; + StaThread.DrainDispatcher(list); + + // A Ctrl+click on the selected row unselects its container, as this does. + Container(list, rows[2]).IsSelected = false; + StaThread.DrainDispatcher(list); + + // The view-model kept the open clip selected, but the row it plays from had lost its highlight. + selection.Selected.ShouldBe(rows[2]); + Container(list, rows[2]).IsSelected.ShouldBeTrue(); + list.SelectedIndex.ShouldBe(2); + }); + } + + [Fact] + public void HighlightRowWhenDeselectIsTurnedDown_DeselectTheSourceAccepts_LeavesTheRowDeselected() + { + StaThread.Run(() => + { + var rows = CreateRows(20); + var list = CreateGroupedList(rows); + var selection = new SelectionSource(); + BindSelection(list, selection); + MainWindow.HighlightRowWhenDeselectIsTurnedDown(list); + list.SelectedItem = rows[2]; + StaThread.DrainDispatcher(list); + + Container(list, rows[2]).IsSelected = false; + StaThread.DrainDispatcher(list); + + selection.Selected.ShouldBeNull(); + Container(list, rows[2]).IsSelected.ShouldBeFalse(); + list.SelectedItem.ShouldBeNull(); + }); + } + + private static void BindSelection(ListBox list, SelectionSource selection) => + list.SetBinding(Selector.SelectedItemProperty, new Binding(nameof(SelectionSource.Selected)) { Source = selection, Mode = BindingMode.TwoWay }); + + private static ListBoxItem Container(ListBox list, Row row) => (ListBoxItem)list.ItemContainerGenerator.ContainerFromItem(row); + + 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, and a selection that doesn't follow the view's current row. + private static ListBox CreateGroupedList(IEnumerable rows) + { + var list = new ListBox { IsSynchronizedWithCurrentItem = false }; + + // 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 = GroupedView(rows); + 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 ListCollectionView GroupedView(IEnumerable rows) + { + var view = new ListCollectionView(rows.ToList()); + view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(Row.Day))); + return view; + } + + // A search or a scan hands the list a new grouped view of the results, the way the clip list's view source does when the search or the library changes. + // The view-model announces the new results in the same pass, before the list lays out its new rows, so the tests show the results before draining too. + private static void Rebind(ListBox list, IEnumerable rows) + { + list.ItemsSource = GroupedView(rows); + } + + 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)); + + // Layout positions carry floating-point error, so a row scrolled flush with the top can sit a hair above it. + const double Tolerance = 0.5; + return bounds.Top >= -Tolerance && bounds.Bottom <= scrollViewer.ActualHeight + Tolerance; + } + + 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; + } + } + } + + // Stands in for the clip library's list selection, which turns down a deselect of the clip the player has open. + private sealed class SelectionSource : INotifyPropertyChanged + { + private object _selected; + + public event PropertyChangedEventHandler PropertyChanged; + + public bool RefusesDeselect { get; init; } + + public object Selected + { + get => _selected; + set + { + if (value is null && RefusesDeselect) + { + return; + } + + _selected = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Selected))); + } + } + } + + 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/MainWindowSearchBoxTests.cs b/SentryDeck.Tests/MainWindowSearchBoxTests.cs new file mode 100644 index 0000000..fed93c6 --- /dev/null +++ b/SentryDeck.Tests/MainWindowSearchBoxTests.cs @@ -0,0 +1,121 @@ +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Input; +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); + }); + } + + [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 @@ -955,11 +1020,16 @@ - + + AutomationProperties.Name="Resize sidebar" + Background="Transparent" + IsTabStop="False" /> - - - - - - + Flyleaf's per-surface key handling would pause/seek a single camera only. + Not focusable, like the slots they move between: they only show video, so as tab stops they were invisible, unnamed stops between the sidebar and the player controls. --> + + + + + + @@ -1000,6 +1071,7 @@ + Focusable="True" + KeyboardNavigation.IsTabStop="False"> @@ -1026,7 +1099,8 @@ Background="Black" ClipToBounds="True"> - + - + - + - + - + - + @@ -1135,13 +1215,13 @@ - + @@ -1164,7 +1244,8 @@ CornerRadius="4" Visibility="{Binding IsCamera, Converter={local:BoolToVisibilityConverter}}"> - + - + - + @@ -1332,6 +1413,115 @@ + + + + + + + + + + + + - - + - + + Value="{Binding Playback.SeekPosition, Mode=TwoWay}"> + + + + + + + - + + FontSize="13" + TextAlignment="Right"> + + + + + + @@ -1857,7 +2073,7 @@ - + @@ -1879,12 +2095,22 @@ - + + + + + + + + + + diff --git a/SentryDeck/MainWindow.xaml.cs b/SentryDeck/MainWindow.xaml.cs index a120af9..d661c98 100644 --- a/SentryDeck/MainWindow.xaml.cs +++ b/SentryDeck/MainWindow.xaml.cs @@ -1,8 +1,11 @@ using System.ComponentModel; using System.Diagnostics; using System.Windows; +using System.Windows.Automation; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Navigation; using System.Windows.Threading; using FlyleafLib.Controls.WPF; @@ -12,7 +15,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 @@ -26,6 +29,7 @@ public partial class MainWindow : Window private bool _isClosing; private bool _isReadyToClose; + private bool _areCameraHostsInWindow; public MainWindow() { @@ -41,8 +45,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; @@ -56,6 +63,22 @@ public MainWindow() } UpdateCameraHostLayout(); + + HookSeekGesture(SeekSlider, _viewModel.Playback.BeginSeek, _viewModel.Playback.EndSeekAsync); + KeepSelectionInView(ClipListBox); + HighlightRowWhenDeselectIsTurnedDown(ClipListBox); + _viewModel.Library.ResultsReplaced += (_, _) => ShowNewResults(ClipListBox); + HookSearchEscape(SearchBox, _viewModel.Library.ClearFilterCommand, LeaveSearchBox); + NameSearchClearButton(SearchBox); + } + + // 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) @@ -65,7 +88,8 @@ private async void Window_ContentRendered(object sender, EventArgs e) private void Window_Closing(object sender, CancelEventArgs e) { - if (_isReadyToClose) + // A window whose constructor failed has nothing to stop, and the app closes it while shutting down because of that failure. + if (_isReadyToClose || _viewModel is null) { return; } @@ -77,17 +101,34 @@ private void Window_Closing(object sender, CancelEventArgs e) return; } + // Closing stops a running export, so the file the user is waiting for is lost unless they choose to stay. + if (_viewModel.Trim.IsExporting && !ConfirmCloseDuringExport()) + { + return; + } + _isClosing = true; IsEnabled = false; // Let WPF finish this Closing callback before requesting the real close. - _ = Dispatcher.InvokeAsync(CloseAfterShutdown); + _ = Dispatcher.InvokeAsync(CloseAfterShutdownAsync); } - private void CloseAfterShutdown() + private bool ConfirmCloseDuringExport() => + MessageBox.Show( + this, + "An export is still being saved. Close Sentry Deck anyway?\n\nThe export will be canceled and its unfinished file deleted.", + "Export in progress", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No) == MessageBoxResult.Yes; + + private async Task CloseAfterShutdownAsync() { try { + // FFmpeg runs as its own process, so it would outlive the app and leave its temporary script behind. + await _viewModel.Trim.CancelExportAsync(); _viewModel.Shutdown(); } catch (Exception ex) @@ -160,18 +201,129 @@ private void ClipListBox_PreviewMouseLeftButtonUp(object sender, MouseButtonEven Dispatcher.BeginInvoke(DispatcherPriority.Input, new Action(() => Keyboard.Focus(VideoContainer))); } - private void SeekSlider_PreviewMouseDown(object sender, MouseButtonEventArgs e) + /// + /// Starts a seek gesture on every press of the seek bar and ends it on the release. + /// + /// + /// The handlers see handled events too because a press on the rail never reaches an ordinary handler: with IsMoveToPointEnabled, Slider's own class handler jumps the thumb to the press and marks it handled first. + /// Missing that press left the gesture unstarted, so while playing the position sync put the thumb back before the release seek read it, and the click was ignored. + /// Only the thumb captures the mouse by itself, so the bar captures it for a press anywhere else: a press released outside the bar must still end the gesture, or playback would stay held paused. + /// + internal static void HookSeekGesture(Slider slider, Action beginSeek, Func endSeekAsync) { - _viewModel.Playback.BeginSeek(); + slider.AddHandler( + PreviewMouseDownEvent, + new MouseButtonEventHandler((_, _) => + { + beginSeek(); + + if (slider.Template?.FindName("PART_Track", slider) is not Track { Thumb.IsMouseOver: true }) + { + slider.CaptureMouse(); + } + }), + handledEventsToo: true); + + slider.AddHandler( + PreviewMouseUpEvent, + new MouseButtonEventHandler(async (_, _) => + { + slider.ReleaseMouseCapture(); + await endSeekAsync(); + }), + 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(); + } + }); + } + + /// + /// Highlights a row again when the view-model turns down its deselect, as it does for the open clip's row. + /// + /// + /// WPF unhighlights the row first and only then hears, through the selection binding, that the view-model kept it, so the list ends up pointing at a row that shows as unselected. + /// Selecting it again has to wait until the list has finished its own selection change. + /// + internal static void HighlightRowWhenDeselectIsTurnedDown(ListBox list) + { + list.SelectionChanged += (_, _) => + list.Dispatcher.BeginInvoke(DispatcherPriority.Background, () => + { + if (list.SelectedIndex < 0 + && list.SelectedItem is { } kept + && list.ItemContainerGenerator.ContainerFromItem(kept) is ListBoxItem { IsSelected: false } row) + { + row.IsSelected = true; + } + }); + } + + /// + /// Shows the start of a new search's results, or of a newly loaded library: the selected clip's row when there is one, otherwise the top of the list. + /// + /// + /// The list kept the scroll offset it had before, which lands on unrelated rows of the new results, so neither the playing clip's row nor the best matches were on screen. + /// After picking another folder, its newest clip and that day's header sat above the view. + /// A search that keeps the selected clip doesn't change the selection, and a newly picked folder opens with nothing selected, so scrolls for neither. + /// The scroll waits for the list's next layout, which builds the rows of the new results. + /// + internal static void ShowNewResults(ListBox list) + { + list.Dispatcher.BeginInvoke(DispatcherPriority.Background, () => + { + if (list.SelectedItem is { } item) + { + list.ScrollIntoView(item); + } + else + { + FindScrollViewer(list)?.ScrollToTop(); + } + }); } - private async void SeekSlider_PreviewMouseUp(object sender, MouseButtonEventArgs e) + // The list's own ScrollViewer comes before any inside its rows, because the rows are laid out within it. + private static ScrollViewer FindScrollViewer(DependencyObject parent) { - await _viewModel.Playback.EndSeekAsync(); + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if ((child as ScrollViewer ?? FindScrollViewer(child)) is { } scrollViewer) + { + return scrollViewer; + } + } + + return null; } // 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. - // PreviewMouseDown has already called BeginSeek by the time this fires, so even a plain click issues one keyframe scrub seek here, which is harmless since the accurate mouse-up seek runs behind the same serialized lock and lands last. + // 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. private void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) { _viewModel.Playback.OnSeekSliderValueChanged(); @@ -193,6 +345,62 @@ 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; + }; + } + + /// + /// Names the clear button that WPF's Fluent theme builds into the search box. + /// + /// + /// The theme ships the button without a name, so screen readers announced it only as "button". + /// It only appears while the box has keyboard focus, so naming it then also covers a template the theme swapped in after the window loaded. + /// + internal static void NameSearchClearButton(TextBox searchBox) + { + searchBox.GotKeyboardFocus += (_, _) => + { + if (searchBox.Template?.FindName("DeleteButton", searchBox) is Button clearButton) + { + AutomationProperties.SetName(clearButton, "Clear search"); + clearButton.ToolTip = "Clear search (Esc)"; + } + }; + } + + // 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) { @@ -230,7 +438,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 fb94bd4..4e44791 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; @@ -26,12 +28,16 @@ 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. /// 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, @@ -57,7 +67,6 @@ public MainWindowViewModel( Trim = new TrimViewModel( Playback, Cameras, - Error, clipExporter ?? new ClipExporter(PackageManager.FindFFmpegDirectory), savePathPicker, exportMediaSourceBuilder ?? new FfconcatMediaSourceBuilder()); @@ -76,6 +85,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); } /// @@ -84,7 +96,7 @@ public MainWindowViewModel( public event EventHandler SearchBoxFocusRequested; /// - /// The notice over the video area; every feature reports errors through it. + /// The notice over the video area; features report errors through it, except exports, which mustn't hide a playing clip. /// public ErrorOverlayViewModel Error { get; } = new(); @@ -122,14 +134,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; @@ -148,15 +163,7 @@ public async Task InitializeAsync() _ = About.CheckForUpdatesAsync(); #endif - if (_flyleafRuntime.TryStart()) - { - InitializePlayer(); - await Library.ReloadAsync(); - } - else - { - ShowFFmpegMissingError(); - } + await StartPlaybackAsync(); } public void Shutdown() @@ -217,31 +224,37 @@ private void ToggleAbout() ShowAboutPage = !ShowAboutPage; } + // The page covers the player and blocks its shortcuts, and TeslaCam footage has no sound, so a clip left running behind it plays through footage nobody sees. + // It stays paused when the page closes, so the user comes back to the frame they left. + partial void OnShowAboutPageChanged(bool value) + { + if (value && Playback.IsPlaying) + { + _ = Playback.PauseAsync(); + } + } + [RelayCommand] 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 { @@ -249,17 +262,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) @@ -312,7 +336,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); } @@ -396,7 +420,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/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 e65c2ec..71ca132 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 or reaches the end (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. @@ -54,6 +55,15 @@ 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. + /// 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); + private readonly string _primaryCamera; private readonly ICameraPlayer _primaryPlayer; private readonly IReadOnlyDictionary _players; @@ -61,9 +71,11 @@ 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; + private (CamClip Clip, TimeSpan Position, bool Play)? _requestedStart; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanPlayPause))] @@ -160,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; @@ -191,9 +209,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); }); @@ -221,14 +247,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. @@ -283,6 +315,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; @@ -298,8 +333,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); }); @@ -331,6 +366,39 @@ 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. + /// 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(); @@ -383,7 +451,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); @@ -398,7 +466,7 @@ await RunOperationAsync(session, "Playback error", async token => { try { - await OpenClipCoreAsync(session, token); + await OpenClipCoreAsync(session, startPosition, play, token); } finally { @@ -410,7 +478,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; @@ -435,7 +503,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); } /// @@ -462,16 +530,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; } @@ -483,6 +555,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 +571,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(); } @@ -523,6 +598,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)) @@ -603,7 +731,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)) @@ -611,7 +739,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) @@ -632,7 +769,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 +777,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 +793,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, PausedAlignmentTolerance)) .ToList(); if (drifted.Count == 0) @@ -665,7 +802,76 @@ 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)))); + } + + /// + /// 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, _players.Values)) + { + Position = anchor; + } + } + + /// + /// 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. + /// 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, IEnumerable players) + { + var positionsAreTrustworthy = PlaybackSpeed <= 1.0; + var outOfStep = players + .Where(player => player.IsOpen && !(positionsAreTrustworthy && IsAlignedWith(player, anchor, PausedAlignmentTolerance))) + .ToList(); + + if (outOfStep.Count == 0) + { + return false; + } + + 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); + } + + /// + /// 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() @@ -787,7 +993,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) @@ -824,7 +1033,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. + // A side camera that lagged behind can still be mid-stream, half a second or more short of its own end. + // So every camera is moved onto its last frame, and the readout follows a front that had to move. + // These are paused seeks, which the Flyleaf player decodes at real time, so even a camera stopped mid-stream lands on a frame instead of parking its decoder. + Position = await LineUpCamerasAsync(Duration, _players.Values) ? SeekableEnd : Duration; }); } @@ -869,7 +1082,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) { @@ -883,7 +1096,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) @@ -929,6 +1142,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); } @@ -974,6 +1192,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) @@ -1019,6 +1267,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; } diff --git a/SentryDeck/Resources/Theme.xaml b/SentryDeck/Resources/Theme.xaml index bb22179..d1bd5c1 100644 --- a/SentryDeck/Resources/Theme.xaml +++ b/SentryDeck/Resources/Theme.xaml @@ -130,19 +130,39 @@ ============================================================ --> - + - +