Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
930ba34
Keep the event of clips stamped just past their last frame
danielchalmers Oct 6, 2026
dfd0940
Play the intact numbered copy when the original footage file is damaged
danielchalmers Oct 6, 2026
8f26702
Stop blaming the car's encryption for zero-filled or text footage files
danielchalmers Oct 6, 2026
8479a36
Play clips whose folder was renamed to a very long name
danielchalmers Oct 6, 2026
ce73b99
Keep clips whose event.json repeats a field
danielchalmers Oct 6, 2026
d90c849
Find clips under hidden or system folders
danielchalmers Oct 6, 2026
9054c14
Scan large clip libraries without holding every folder open
danielchalmers Oct 6, 2026
09ed5fb
Show each clip's real length in the list
danielchalmers Oct 6, 2026
43380d3
Label saved and Sentry clips without event data by their folder
danielchalmers Oct 6, 2026
dc2bbaa
Show the city on a clip card even when its coordinates are missing
danielchalmers Oct 6, 2026
752fd10
Show the missing-thumbnail glyph for a corrupt or empty thumb.png
danielchalmers Oct 6, 2026
0ad5a74
Keep the cameras playing after a seek to the end of a clip
danielchalmers Oct 6, 2026
e6965fc
Line the cameras up on one moment when fast playback pauses
danielchalmers Oct 6, 2026
32d331f
Show the front's last frame when fast playback reaches the end
danielchalmers Oct 6, 2026
9cc36be
Keep playing from the same moment when the next video file can't be read
danielchalmers Oct 6, 2026
d3ee23f
Move five seconds for every repeat of a held arrow key
danielchalmers Oct 6, 2026
07f6d02
Say why a clip's front video can't be played
danielchalmers Oct 6, 2026
e93b86f
Keep the side cameras from freezing after a seek near their end
danielchalmers Oct 6, 2026
a4d1fc1
Warn when a clip delete is permanent, and never half-delete a clip
danielchalmers Oct 6, 2026
0945c51
Continue Next and Previous from a deleted clip's place in the list
danielchalmers Oct 6, 2026
5a98c2f
Keep the player's place when a delete doesn't happen, and drop gone c…
danielchalmers Oct 6, 2026
d758afb
Copy the Tesla folder name and an ISO 8601 timestamp from the clip menu
danielchalmers Oct 6, 2026
cf61d92
Make Next and Previous follow the list when several folders are loaded
danielchalmers Oct 6, 2026
5a91d6f
Keep playing the open clip in view when a search hides it
danielchalmers Oct 6, 2026
dd771bd
Keep Next and Previous inside the search results
danielchalmers Oct 6, 2026
cc48819
Say when a folder has no clips or can't be found
danielchalmers Oct 6, 2026
9d14373
Keep the open clip where it was when rescanning
danielchalmers Oct 6, 2026
d38d39b
Find clips by the dates the list shows and by several words at once
danielchalmers Oct 6, 2026
72fad25
Remember the picked dashcam folders across restarts
danielchalmers Oct 6, 2026
b4ee188
Keep the controls working when you click the clip that is still open
danielchalmers Oct 6, 2026
cc86b9b
Let Stop unload a paused or finished clip
danielchalmers Oct 6, 2026
295f956
Show the now-playing badge again when Play restarts a stopped clip
danielchalmers Oct 6, 2026
5ca55b3
Keep the seek-bar markers in place and disable Event after Stop
danielchalmers Oct 6, 2026
7eb400b
Close the trim panel when Stop is pressed while trimming
danielchalmers Oct 6, 2026
89a5a1d
Keep the camera view you picked when you move to another clip
danielchalmers Oct 6, 2026
828ece4
Only offer camera tiles for cameras the clip can play
danielchalmers Oct 6, 2026
55d5aa9
Keep the Download FFmpeg prompt until FFmpeg is installed
danielchalmers Oct 6, 2026
7ea2fba
Keep keyboard focus on Previous frame, Next frame and Event after pre…
danielchalmers Oct 6, 2026
38bde6f
Start the next clip where it opens when the seek thumb was held acros…
danielchalmers Oct 6, 2026
ef2c01b
Round the selected trim length to the nearest second
danielchalmers Oct 6, 2026
339c98d
Say which camera an export saves
danielchalmers Oct 6, 2026
2e78421
Suggest exports in the Videos folder with a sortable name
danielchalmers Oct 6, 2026
8d93297
Don't let a failed export hide the playing clip
danielchalmers Oct 6, 2026
b0b1eb3
Show export progress in the app and stop FFmpeg when it closes
danielchalmers Oct 6, 2026
7c2cdba
Say when the update check failed instead of "up to date"
danielchalmers Oct 6, 2026
626e0b5
Show the release version in About as 1.1.0, not 1.1.0.0
danielchalmers Oct 6, 2026
fec41bd
Pause playback when the About page opens
danielchalmers Oct 6, 2026
3b95f9f
Keep a week of logs when several instances run
danielchalmers Oct 6, 2026
fdb349d
Exit with an error when the main window can't open
danielchalmers Oct 6, 2026
b96b005
Bundle WPF's native libraries into the release exe
danielchalmers Oct 6, 2026
2c9892e
Seek to the clicked point when clicking the seek bar during playback
danielchalmers Oct 6, 2026
56a1a3d
Show the event marker's tooltip when hovering it
danielchalmers Oct 6, 2026
a9d258b
Make gap markers on the seek bar stand out from chunk seams
danielchalmers Oct 6, 2026
9b492ad
Keep the trim range visible over the played part of the seek bar
danielchalmers Oct 6, 2026
f2736b8
Stop the seek bar shifting when the time readout gains a digit
danielchalmers Oct 6, 2026
6b636d1
Scroll the clip list to the clip that Next or Previous opened
danielchalmers Oct 6, 2026
89a176c
Let Escape clear the search box and then leave it
danielchalmers Oct 6, 2026
46fe965
Give the search box's clear button a name screen readers can read
danielchalmers Oct 6, 2026
8d32bc1
Make disabled buttons look disabled
danielchalmers Oct 6, 2026
d0c0283
Show the whole trim hint at smaller window sizes
danielchalmers Oct 6, 2026
48796f4
Show the F1 and Esc help shortcuts in tooltips and the shortcut list
danielchalmers Oct 6, 2026
3741e85
Switch cameras from a tile chosen with Enter or a screen reader
danielchalmers Oct 6, 2026
0fa72fb
Stop screen readers announcing class names and raw numbers around the…
danielchalmers Oct 6, 2026
5bf9e19
Tab from the sidebar straight to the camera tiles and player controls
danielchalmers Oct 6, 2026
1fd52dd
Read clips and day groups in the list as dates, reasons and places
danielchalmers Oct 6, 2026
608d6c6
Let screen readers hear whether a clip plays, trim is open and how fa…
danielchalmers Oct 6, 2026
e7705cc
Keep the seek bar on screen when the sidebar is dragged wide
danielchalmers Oct 6, 2026
49e8827
Show every camera's last frame when fast playback reaches the end
danielchalmers Oct 6, 2026
73cfb6c
Keep the open clip selected when its row is deselected
danielchalmers Oct 6, 2026
39ecb8b
Open the delete, export and folder dialogs over the Sentry Deck window
danielchalmers Oct 6, 2026
7394871
Show the playing clip's row or the top results after a search
danielchalmers Oct 6, 2026
85e5270
Open a newly picked folder's clip list at its newest clip
danielchalmers Oct 6, 2026
54fb45c
Name exports by the times the readout showed at the trim marks
danielchalmers Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/actions/full-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions SentryDeck.Data/CamChunk.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ public CamChunk(DateTime timestamp, IEnumerable<CamFile> 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<string, CamFile> BuildFileMap(IEnumerable<CamFile> files)
{
var map = new Dictionary<string, CamFile>();
Expand All @@ -38,16 +41,20 @@ private static IReadOnlyDictionary<string, CamFile> BuildFileMap(IEnumerable<Cam
.ThenBy(file => 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());
}
}

Expand Down
171 changes: 132 additions & 39 deletions SentryDeck.Data/CamClip.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.IO.Enumeration;
using System.Text.RegularExpressions;
using Serilog;

Expand Down Expand Up @@ -39,6 +40,18 @@ public partial record class CamClip
/// </summary>
public string ThumbnailPath { get; private init; }

/// <summary>
/// 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 <see cref="ClipTimeline.EstimatedChunkSeconds"/>.
/// </summary>
public TimeSpan EstimatedDuration { get; private init; }

/// <summary>
/// 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.
/// </summary>
public CamSourceFolder SourceFolder { get; private init; }

public CamClip(string path, string name, DateTime timestamp, IEnumerable<CamChunk> chunks, CamEvent camEvent)
{
FullPath = Path.GetFullPath(path);
Expand All @@ -47,6 +60,8 @@ public CamClip(string path, string name, DateTime timestamp, IEnumerable<CamChun
Chunks = chunks.ToList();
Event = camEvent;
ThumbnailPath = Path.Combine(FullPath, "thumb.png");
EstimatedDuration = new ClipTimeline(Chunks).Duration;
SourceFolder = SourceFolderOf(FullPath);
}

/// <summary>
Expand Down Expand Up @@ -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),
};
}

/// <summary>
/// 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.
/// </summary>
private static TimeSpan EstimateDuration(IReadOnlyList<CamChunk> 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;
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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,
};

/// <summary>
/// Finds clip folders inside the specified root directory.
/// </summary>
Expand Down Expand Up @@ -119,55 +195,72 @@ private static CamClip TryMap(string directory)

public override string ToString() => $"{Name}";

/// <summary>
/// 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.
/// </summary>
private static IEnumerable<string> EnumerateClipCandidates(string rootDirectory)
{
yield return rootDirectory;
var pending = new Stack<string>();
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<string> 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<string> 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,
};

/// <summary>
/// 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.
/// </summary>
private static List<string> ListSubfolders(string directory)
=> new FileSystemEnumerable<string>(directory, static (ref FileSystemEntry entry) => entry.ToSpecifiedFullPath(), SubfolderListingOptions)
{
ShouldIncludePredicate = static (ref FileSystemEntry entry) => entry.IsDirectory && !IsHousekeepingFolder(entry.FileName),
}.ToList();

/// <summary>
/// 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.
/// </summary>
private static bool IsHousekeepingFolder(ReadOnlySpan<char> name)
=> name.Equals("$RECYCLE.BIN", StringComparison.OrdinalIgnoreCase)
|| name.Equals("System Volume Information", StringComparison.OrdinalIgnoreCase);

[GeneratedRegex(@"(?<date>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})")]
private static partial Regex FolderNameRegex();
}
Loading