From bf34082d24d86b04975f2023260a44f1f1774791 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 20:16:47 +0000
Subject: [PATCH 1/6] Initial plan
From 48c04e5722ab91c06c2f28078f7b27521fc1e1de Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 20:21:22 +0000
Subject: [PATCH 2/6] Implement architecture-aware FFmpeg download for ARM64
support
Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com>
---
SentryReplay.Tests/PackageManagerTests.cs | 29 +++++++++++++++++++++++
SentryReplay/PackageManager.cs | 15 +++++++++++-
TeslaCam/PackageManager.cs | 15 +++++++++++-
3 files changed, 57 insertions(+), 2 deletions(-)
create mode 100644 SentryReplay.Tests/PackageManagerTests.cs
diff --git a/SentryReplay.Tests/PackageManagerTests.cs b/SentryReplay.Tests/PackageManagerTests.cs
new file mode 100644
index 0000000..230095f
--- /dev/null
+++ b/SentryReplay.Tests/PackageManagerTests.cs
@@ -0,0 +1,29 @@
+using System.Runtime.InteropServices;
+using Shouldly;
+
+namespace SentryReplay.Tests;
+
+///
+/// Tests for PackageManager functionality.
+///
+public class PackageManagerTests
+{
+ [Fact]
+ public void GetFFmpegDownloadUrl_ReturnsValidUrl()
+ {
+ // We can't directly test the private method, but we can verify the logic indirectly
+ // by checking that the current architecture is supported
+ var architecture = RuntimeInformation.ProcessArchitecture;
+
+ // Verify that the architecture is one we support
+ architecture.ShouldBeOneOf(Architecture.X64, Architecture.Arm64, Architecture.X86, Architecture.Arm);
+ }
+
+ [Fact]
+ public void FindFFmpegDirectories_ReturnsEnumerable()
+ {
+ // Test that the method returns an enumerable (even if empty)
+ var directories = PackageManager.FindFFmpegDirectories(".");
+ directories.ShouldNotBeNull();
+ }
+}
diff --git a/SentryReplay/PackageManager.cs b/SentryReplay/PackageManager.cs
index 50bd70a..6e9015c 100644
--- a/SentryReplay/PackageManager.cs
+++ b/SentryReplay/PackageManager.cs
@@ -1,6 +1,7 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
+using System.Runtime.InteropServices;
using Serilog;
namespace SentryReplay;
@@ -26,7 +27,7 @@ private static void ExtractZipFile(string zipFilePath, string extractPath)
public static async Task DownloadAndExtractFFmpeg()
{
var outputFolder = Path.GetFullPath("ffmpeg");
- var url = "https://github.com/GyanD/codexffmpeg/releases/download/7.0/ffmpeg-7.0-full_build-shared.zip"; // TODO: ARM64 builds?
+ var url = GetFFmpegDownloadUrl();
var tempPath = Path.GetTempFileName();
Log.Information("Getting ffmpeg");
@@ -40,6 +41,18 @@ public static async Task DownloadAndExtractFFmpeg()
File.Delete(tempPath);
}
+ private static string GetFFmpegDownloadUrl()
+ {
+ var architecture = RuntimeInformation.ProcessArchitecture;
+
+ return architecture switch
+ {
+ Architecture.Arm64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-winarm64-gpl-shared.zip",
+ Architecture.X64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip",
+ _ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip", // Default to x64
+ };
+ }
+
public static IEnumerable FindFFmpegDirectories(string searchDirectory = ".")
{
foreach (var path in Directory.EnumerateFiles(searchDirectory, "ffmpeg.exe", SearchOption.AllDirectories))
diff --git a/TeslaCam/PackageManager.cs b/TeslaCam/PackageManager.cs
index 619af3b..f2e9a2c 100644
--- a/TeslaCam/PackageManager.cs
+++ b/TeslaCam/PackageManager.cs
@@ -1,6 +1,7 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
+using System.Runtime.InteropServices;
using Serilog;
namespace TeslaCam;
@@ -46,7 +47,7 @@ private static void ExtractZipFile(string zipFilePath, string extractPath)
public static async Task DownloadAndExtractFFmpeg()
{
var outputFolder = Path.GetFullPath("ffmpeg");
- var url = "https://github.com/GyanD/codexffmpeg/releases/download/7.0/ffmpeg-7.0-full_build-shared.zip";
+ var url = GetFFmpegDownloadUrl();
var tempPath = Path.GetTempFileName();
try
@@ -74,6 +75,18 @@ public static async Task DownloadAndExtractFFmpeg()
}
}
+ private static string GetFFmpegDownloadUrl()
+ {
+ var architecture = RuntimeInformation.ProcessArchitecture;
+
+ return architecture switch
+ {
+ Architecture.Arm64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-winarm64-gpl-shared.zip",
+ Architecture.X64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip",
+ _ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip", // Default to x64
+ };
+ }
+
public static IEnumerable FindFFmpegDirectories(string searchDirectory = ".")
{
foreach (var path in Directory.EnumerateFiles(searchDirectory, "ffmpeg.exe", SearchOption.AllDirectories))
From d8b1fead1f25b6d0196fcc3b4d6b0c833d56bf1b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 20:22:48 +0000
Subject: [PATCH 3/6] Update comment to be generic about zip structure
Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com>
---
TeslaCam/PackageManager.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/TeslaCam/PackageManager.cs b/TeslaCam/PackageManager.cs
index f2e9a2c..42e9ecf 100644
--- a/TeslaCam/PackageManager.cs
+++ b/TeslaCam/PackageManager.cs
@@ -20,7 +20,7 @@ private static async Task DownloadFile(string url, string savePath)
private static void ExtractZipFile(string zipFilePath, string extractPath)
{
- // Extract and flatten nested directories - the zip contains ffmpeg-7.0-full_build-shared/bin/*.dll and ffmpeg.exe
+ // Extract and flatten nested directories - the zip contains a bin directory with ffmpeg.exe and required DLLs
using var archive = ZipFile.OpenRead(zipFilePath);
foreach (var entry in archive.Entries)
From 9da72af7b0ab118fcc40fc41c2f28381227f963e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 20:36:42 +0000
Subject: [PATCH 4/6] Throw error for unsupported architectures and remove
unused TeslaCam folder
Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com>
---
SentryReplay/PackageManager.cs | 2 +-
TeslaCam/Converters.cs | 74 ----
TeslaCam/MainWindow.xaml | 389 ------------------
TeslaCam/MainWindow.xaml.cs | 723 ---------------------------------
TeslaCam/PackageManager.cs | 97 -----
5 files changed, 1 insertion(+), 1284 deletions(-)
delete mode 100644 TeslaCam/Converters.cs
delete mode 100644 TeslaCam/MainWindow.xaml
delete mode 100644 TeslaCam/MainWindow.xaml.cs
delete mode 100644 TeslaCam/PackageManager.cs
diff --git a/SentryReplay/PackageManager.cs b/SentryReplay/PackageManager.cs
index 6e9015c..2601bb4 100644
--- a/SentryReplay/PackageManager.cs
+++ b/SentryReplay/PackageManager.cs
@@ -49,7 +49,7 @@ private static string GetFFmpegDownloadUrl()
{
Architecture.Arm64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-winarm64-gpl-shared.zip",
Architecture.X64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip",
- _ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip", // Default to x64
+ _ => throw new PlatformNotSupportedException($"Unsupported architecture: {architecture}. Only x64 and ARM64 are supported.")
};
}
diff --git a/TeslaCam/Converters.cs b/TeslaCam/Converters.cs
deleted file mode 100644
index 09b2801..0000000
--- a/TeslaCam/Converters.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using System.Globalization;
-using System.Windows;
-using System.Windows.Data;
-
-namespace TeslaCam;
-
-///
-/// Converts a TimeSpan to a formatted string (mm:ss or hh:mm:ss).
-///
-public class TimeSpanToStringConverter : IValueConverter
-{
- public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
- {
- if (value is TimeSpan ts)
- {
- return ts.TotalHours >= 1
- ? ts.ToString(@"h\:mm\:ss")
- : ts.ToString(@"m\:ss");
- }
- return "0:00";
- }
-
- public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
- {
- throw new NotImplementedException();
- }
-}
-
-///
-/// Converts a boolean to Visibility (true = Visible, false = Collapsed).
-/// Supports "Inverse" parameter to invert the logic.
-///
-public class BoolToVisibilityConverter : IValueConverter
-{
- public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
- {
- if (value is bool b)
- {
- var inverse = parameter?.ToString()?.Equals("Inverse", StringComparison.OrdinalIgnoreCase) ?? false;
- var result = inverse ? !b : b;
- return result ? Visibility.Visible : Visibility.Collapsed;
- }
- return Visibility.Collapsed;
- }
-
- public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
- {
- throw new NotImplementedException();
- }
-}
-
-///
-/// Inverts a boolean value.
-///
-public class InverseBoolConverter : IValueConverter
-{
- public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
- {
- if (value is bool b)
- {
- return !b;
- }
- return false;
- }
-
- public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
- {
- if (value is bool b)
- {
- return !b;
- }
- return false;
- }
-}
diff --git a/TeslaCam/MainWindow.xaml b/TeslaCam/MainWindow.xaml
deleted file mode 100644
index badee6f..0000000
--- a/TeslaCam/MainWindow.xaml
+++ /dev/null
@@ -1,389 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/TeslaCam/MainWindow.xaml.cs b/TeslaCam/MainWindow.xaml.cs
deleted file mode 100644
index 67df5a8..0000000
--- a/TeslaCam/MainWindow.xaml.cs
+++ /dev/null
@@ -1,723 +0,0 @@
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
-using System.Windows;
-using System.Windows.Input;
-using Microsoft.Win32;
-using Serilog;
-using TeslaCam.Data;
-using Unosquare.FFME;
-
-namespace TeslaCam;
-
-///
-/// Interaction logic for MainWindow.xaml
-/// A robust video player for TeslaCam footage with seamless playback.
-///
-public partial class MainWindow : Window, INotifyPropertyChanged
-{
- private readonly List _allClips = [];
- private VideoPlayerController _playerController;
- private bool _isSeeking;
-
- private string _filterText = string.Empty;
- private CamClip _selectedClip;
- private string _errorTitle;
- private string _errorDetails;
- private bool _showErrorOverlay;
- private bool _showFFmpegDownloadButton;
- private bool _isLoading;
- private bool _isRendering;
- private double _renderProgress;
- private double _seekPosition;
- private bool _isPlaying;
- private double _selectedPlaybackSpeed = 1.0;
-
- public MainWindow()
- {
- InitializeComponent();
- DataContext = this;
- }
-
- #region Bindable Properties
-
- public string FilterText
- {
- get => _filterText;
- set
- {
- if (SetProperty(ref _filterText, value))
- {
- OnPropertyChanged(nameof(FilteredClips));
- }
- }
- }
-
- public IReadOnlyList FilteredClips => _allClips
- .Where(c => string.IsNullOrWhiteSpace(FilterText) ||
- c.Name.Contains(FilterText, StringComparison.CurrentCultureIgnoreCase) ||
- c.FullPath.Contains(FilterText, StringComparison.CurrentCultureIgnoreCase))
- .OrderByDescending(c => c.Timestamp)
- .ThenBy(c => c.Name)
- .ToList();
-
- public CamClip SelectedClip
- {
- get => _selectedClip;
- set
- {
- if (SetProperty(ref _selectedClip, value) && value is not null)
- {
- _ = PlaySelectedClipAsync();
- }
- }
- }
-
- public string ErrorTitle
- {
- get => _errorTitle;
- set => SetProperty(ref _errorTitle, value);
- }
-
- public string ErrorDetails
- {
- get => _errorDetails;
- set => SetProperty(ref _errorDetails, value);
- }
-
- public bool ShowErrorOverlay
- {
- get => _showErrorOverlay;
- set
- {
- SetProperty(ref _showErrorOverlay, value);
- OnPropertyChanged(nameof(ShowStatusOverlay));
- }
- }
-
- public bool ShowFFmpegDownloadButton
- {
- get => _showFFmpegDownloadButton;
- set => SetProperty(ref _showFFmpegDownloadButton, value);
- }
-
- public bool ShowStatusOverlay => IsLoading || ShowErrorOverlay;
-
- public bool HasError => ShowErrorOverlay;
-
- public bool IsLoading
- {
- get => _isLoading;
- set
- {
- SetProperty(ref _isLoading, value);
- OnPropertyChanged(nameof(CanPlayPause));
- OnPropertyChanged(nameof(CanStop));
- OnPropertyChanged(nameof(LoadingStatusText));
- OnPropertyChanged(nameof(IsIndeterminateProgress));
- OnPropertyChanged(nameof(ShowStatusOverlay));
- }
- }
-
- public bool IsRendering
- {
- get => _isRendering;
- set
- {
- SetProperty(ref _isRendering, value);
- OnPropertyChanged(nameof(LoadingStatusText));
- OnPropertyChanged(nameof(IsIndeterminateProgress));
- }
- }
-
- public double RenderProgress
- {
- get => _renderProgress;
- set
- {
- SetProperty(ref _renderProgress, value);
- OnPropertyChanged(nameof(RenderProgressPercent));
- OnPropertyChanged(nameof(LoadingStatusText));
- }
- }
-
- public int RenderProgressPercent => (int)(RenderProgress * 100);
-
- public bool IsIndeterminateProgress => IsLoading && !IsRendering;
-
- public string LoadingStatusText => IsRendering
- ? $"Rendering... {RenderProgressPercent}%"
- : "Loading...";
-
- public bool HasNoClipSelected => SelectedClip is null && !IsLoading;
-
- public double SeekPosition
- {
- get => _seekPosition;
- set
- {
- if (SetProperty(ref _seekPosition, value))
- {
- OnPropertyChanged(nameof(PositionText));
- }
- }
- }
-
- public string PositionText
- {
- get
- {
- var duration = _playerController?.Duration ?? TimeSpan.Zero;
- var position = TimeSpan.FromSeconds(SeekPosition * duration.TotalSeconds);
- return FormatTimeSpan(position);
- }
- }
-
- public string DurationText
- {
- get
- {
- var duration = _playerController?.Duration ?? TimeSpan.Zero;
- return FormatTimeSpan(duration);
- }
- }
-
- public bool CanSeek => _playerController is not null && MediaElement?.IsOpen == true && !IsLoading && _playerController.Duration > TimeSpan.Zero;
-
- public bool CanPlayPause => (SelectedClip is not null || IsPlaying) && !IsLoading;
-
- public bool CanStop => IsPlaying || IsLoading;
-
- public bool IsPlaying
- {
- get => _isPlaying;
- set
- {
- if (SetProperty(ref _isPlaying, value))
- {
- OnPropertyChanged(nameof(PlayPauseIcon));
- OnPropertyChanged(nameof(CanPlayPause));
- OnPropertyChanged(nameof(CanStop));
- }
- }
- }
-
- public bool CanGoNext => _playerController?.Playlist.HasNext == true;
-
- public bool CanGoPrevious => _playerController?.Playlist.HasPrevious == true;
-
- public string PlayPauseIcon => IsPlaying ? "⏸" : "▶";
-
- public IReadOnlyList PlaybackSpeedOptions { get; } = [
- 0.25,
- 0.5,
- 0.75,
- 1.0,
- 1.25,
- 1.5,
- 2.0,
- 3.0,
- 4.0,
- ];
-
- public double SelectedPlaybackSpeed
- {
- get => _selectedPlaybackSpeed;
- set
- {
- if (!SetProperty(ref _selectedPlaybackSpeed, value))
- return;
-
- if (_playerController is not null)
- _playerController.PlaybackSpeed = value;
- }
- }
-
- #endregion
-
- #region Initialization
-
- private async void Window_ContentRendered(object sender, EventArgs e)
- {
- // Try to load FFmpeg
- var loaded = TryLoadFFmpeg();
-
- if (!loaded)
- {
- ShowFFmpegDownloadRequired();
- return;
- }
-
- // Initialize player controller
- _playerController = new VideoPlayerController(MediaElement);
- _playerController.PropertyChanged += PlayerController_PropertyChanged;
-
- // Apply initial playback speed selection
- _playerController.PlaybackSpeed = SelectedPlaybackSpeed;
-
- // Wire up media element events for UI updates
- MediaElement.MediaOpened += MediaElement_MediaOpened;
- MediaElement.MediaEnded += MediaElement_MediaEnded;
- MediaElement.MediaFailed += MediaElement_MediaFailed;
-
- // Load clips from common locations
- LoadClips(CamStorage.FindCommonRoots());
- }
-
- private bool TryLoadFFmpeg()
- {
- var directories = PackageManager.FindFFmpegDirectories();
-
- foreach (var directory in directories)
- {
- Library.FFmpegDirectory = directory;
- Log.Debug($"Trying to load FFmpeg from {directory}");
-
- try
- {
- var loaded = Library.LoadFFmpeg();
- if (loaded)
- {
- Log.Information($"Loaded FFmpeg from {directory}");
- return true;
- }
- }
- catch (Exception ex)
- {
- Log.Warning(ex, $"Failed to load FFmpeg from {directory}");
- }
- }
-
- return Library.IsInitialized;
- }
-
- private void LoadClips(IEnumerable roots)
- {
- ClearError();
- _allClips.Clear();
-
- var rootList = roots.ToList();
- if (!rootList.Any())
- {
- Log.Information("No TeslaCam roots found");
- ShowError("No TeslaCam Folders Found", "Click 'Select Folder' to choose a folder containing TeslaCam footage.");
- OnPropertyChanged(nameof(FilteredClips));
- OnPropertyChanged(nameof(HasNoClipSelected));
- return;
- }
-
- foreach (var root in rootList)
- {
- Log.Information($"Loading clips from: {root}");
-
- try
- {
- var storage = CamStorage.Map(root);
- _allClips.AddRange(storage.Clips);
- Log.Information($"Found {storage.Clips.Count} clips in {root}");
- }
- catch (UnauthorizedAccessException ex)
- {
- Log.Error(ex, $"Access denied to {root}");
- ShowError("Access Denied", $"Cannot access folder: {root}\n\nCheck that you have permission to read this location.");
- }
- catch (Exception ex)
- {
- Log.Error(ex, $"Failed to load clips from {root}");
- ShowError("Error Loading Clips", $"Failed to load clips from:\n{root}\n\nError: {ex.Message}");
- }
- }
-
- // Update playlist in controller
- if (_playerController is not null)
- {
- _playerController.LoadClips(_allClips);
- }
-
- OnPropertyChanged(nameof(FilteredClips));
- OnPropertyChanged(nameof(HasNoClipSelected));
- Log.Information($"Total clips loaded: {_allClips.Count}");
- }
-
- #endregion
-
- #region Playback
-
- private async Task PlaySelectedClipAsync()
- {
- if (SelectedClip is null || _playerController is null)
- return;
-
- ClearError();
-
- // Don't manage IsLoading here - let the controller do it
- try
- {
- await _playerController.GoToClipAsync(SelectedClip);
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Failed to play clip");
- ShowError("Playback Failed", $"Could not play clip: {SelectedClip.Name}\n\nError: {ex.Message}");
- }
-
- UpdateAllPlaybackProperties();
- }
-
- private void UpdateAllPlaybackProperties()
- {
- OnPropertyChanged(nameof(CanPlayPause));
- OnPropertyChanged(nameof(CanGoNext));
- OnPropertyChanged(nameof(CanGoPrevious));
- OnPropertyChanged(nameof(PlayPauseIcon));
- OnPropertyChanged(nameof(CanSeek));
- OnPropertyChanged(nameof(DurationText));
- OnPropertyChanged(nameof(HasNoClipSelected));
- }
-
- #endregion
-
- #region Event Handlers
-
- private void PlayerController_PropertyChanged(object sender, PropertyChangedEventArgs e)
- {
- Dispatcher.Invoke(() =>
- {
- switch (e.PropertyName)
- {
- case nameof(VideoPlayerController.IsLoading):
- IsLoading = _playerController.IsLoading;
- break;
- case nameof(VideoPlayerController.IsRendering):
- IsRendering = _playerController.IsRendering;
- break;
- case nameof(VideoPlayerController.RenderProgress):
- RenderProgress = _playerController.RenderProgress;
- break;
- case nameof(VideoPlayerController.IsPlaying):
- IsPlaying = _playerController.IsPlaying;
- break;
- case nameof(VideoPlayerController.Duration):
- OnPropertyChanged(nameof(DurationText));
- OnPropertyChanged(nameof(CanSeek));
- // If duration changes (new stream), keep seek slider consistent
- if (!_isSeeking)
- {
- var dur = _playerController.Duration;
- if (dur.TotalSeconds > 0)
- {
- SeekPosition = Math.Clamp(_playerController.Position.TotalSeconds / dur.TotalSeconds, 0, 1);
- }
- else
- {
- SeekPosition = 0;
- }
- }
- break;
- case nameof(VideoPlayerController.Position):
- if (!_isSeeking)
- {
- var dur = _playerController.Duration;
- if (dur.TotalSeconds > 0)
- {
- SeekPosition = Math.Clamp(_playerController.Position.TotalSeconds / dur.TotalSeconds, 0, 1);
- }
- }
- OnPropertyChanged(nameof(PositionText));
- break;
- case nameof(VideoPlayerController.ErrorMessage):
- if (_playerController.ErrorMessage is not null)
- {
- ShowError("Playback Error", _playerController.ErrorMessage);
- }
- break;
- }
- });
- }
-
- private void MediaElement_MediaOpened(object sender, Unosquare.FFME.Common.MediaOpenedEventArgs e)
- {
- Dispatcher.Invoke(() =>
- {
- IsLoading = false;
- UpdateAllPlaybackProperties();
- });
- }
-
- private void MediaElement_MediaEnded(object sender, EventArgs e)
- {
- Dispatcher.Invoke(() =>
- {
- OnPropertyChanged(nameof(PlayPauseIcon));
- });
- }
-
- private void MediaElement_MediaFailed(object sender, Unosquare.FFME.Common.MediaFailedEventArgs e)
- {
- Dispatcher.Invoke(() =>
- {
- IsLoading = false;
- ShowError("Media Playback Failed", $"The video could not be played.\n\nError: {e.ErrorException?.Message}");
- UpdateAllPlaybackProperties();
- });
- }
-
- private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
- {
- Log.Debug("User selecting folder");
-
- var dialog = new OpenFolderDialog
- {
- Multiselect = true,
- Title = "Select a folder containing TeslaCam footage",
- };
-
- if (dialog.ShowDialog() == true)
- {
- // Stop any current playback before loading new clips
- if (_playerController is not null)
- {
- await _playerController.StopAsync();
- }
-
- LoadClips(dialog.FolderNames);
- }
- }
-
- private async void PlayPauseButton_Click(object sender, RoutedEventArgs e)
- {
- if (_playerController is null)
- return;
-
- await _playerController.TogglePlayPauseAsync();
- OnPropertyChanged(nameof(PlayPauseIcon));
- }
-
- private async void StopButton_Click(object sender, RoutedEventArgs e)
- {
- if (_playerController is null)
- return;
-
- await _playerController.StopAsync();
- SeekPosition = 0;
- UpdateAllPlaybackProperties();
- }
-
- private async void PreviousButton_Click(object sender, RoutedEventArgs e)
- {
- if (_playerController is null)
- return;
-
- await _playerController.PreviousAsync();
- _selectedClip = _playerController.CurrentClip;
- OnPropertyChanged(nameof(SelectedClip));
- UpdateAllPlaybackProperties();
- }
-
- private async void NextButton_Click(object sender, RoutedEventArgs e)
- {
- if (_playerController is null)
- return;
-
- await _playerController.NextAsync();
- _selectedClip = _playerController.CurrentClip;
- OnPropertyChanged(nameof(SelectedClip));
- UpdateAllPlaybackProperties();
- }
-
- private async void SeekSlider_PreviewMouseUp(object sender, MouseButtonEventArgs e)
- {
- if (_playerController is null || !CanSeek)
- return;
-
- await SeekToCurrentPosition();
- _isSeeking = false;
- }
-
- private void SeekSlider_PreviewMouseDown(object sender, MouseButtonEventArgs e)
- {
- if (CanSeek)
- {
- _isSeeking = true;
- }
- }
-
- private async void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
- {
- // Only seek when the user is actively dragging
- if (_isSeeking && _playerController is not null && CanSeek)
- {
- await SeekToCurrentPosition();
- }
- }
-
- private async Task SeekToCurrentPosition()
- {
- var duration = _playerController?.Duration ?? TimeSpan.Zero;
- if (duration.TotalSeconds > 0)
- {
- var targetPosition = TimeSpan.FromSeconds(SeekPosition * duration.TotalSeconds);
- await _playerController.SeekAsync(targetPosition);
- }
- }
-
- private async void Window_KeyDown(object sender, KeyEventArgs e)
- {
- if (_playerController is null)
- return;
-
- switch (e.Key)
- {
- case Key.Space:
- await _playerController.TogglePlayPauseAsync();
- OnPropertyChanged(nameof(PlayPauseIcon));
- e.Handled = true;
- break;
-
- case Key.Left:
- if (Keyboard.Modifiers == ModifierKeys.Control && CanGoPrevious)
- {
- await _playerController.PreviousAsync();
- _selectedClip = _playerController.CurrentClip;
- OnPropertyChanged(nameof(SelectedClip));
- }
- else if (CanSeek)
- {
- var pos = _playerController.Position - TimeSpan.FromSeconds(5);
- await _playerController.SeekAsync(pos < TimeSpan.Zero ? TimeSpan.Zero : pos);
- }
- e.Handled = true;
- break;
-
- case Key.Right:
- if (Keyboard.Modifiers == ModifierKeys.Control && CanGoNext)
- {
- await _playerController.NextAsync();
- _selectedClip = _playerController.CurrentClip;
- OnPropertyChanged(nameof(SelectedClip));
- }
- else if (CanSeek)
- {
- var duration = _playerController.Duration;
- var pos = _playerController.Position + TimeSpan.FromSeconds(5);
- await _playerController.SeekAsync(pos > duration ? duration : pos);
- }
- e.Handled = true;
- break;
- }
-
- UpdateAllPlaybackProperties();
- }
-
- private async void Window_Closing(object sender, CancelEventArgs e)
- {
- if (_playerController is not null)
- {
- await _playerController.StopAsync();
- _playerController.Dispose();
- }
-
- await MediaElement.Close();
- }
-
- #endregion
-
- #region Helpers
-
- private static string FormatTimeSpan(TimeSpan ts)
- {
- return ts.TotalHours >= 1
- ? ts.ToString(@"h\:mm\:ss")
- : ts.ToString(@"m\:ss");
- }
-
- private void ShowError(string title, string details)
- {
- ErrorTitle = title;
- ErrorDetails = details;
- ShowErrorOverlay = true;
- ShowFFmpegDownloadButton = false;
- }
-
- private void ShowFFmpegDownloadRequired()
- {
- ErrorTitle = "FFmpeg Required";
- ErrorDetails = "FFmpeg is required for video playback.\n\nClick the button below to download (~80 MB).";
- ShowErrorOverlay = true;
- ShowFFmpegDownloadButton = true;
- }
-
- private void ClearError()
- {
- ShowErrorOverlay = false;
- ShowFFmpegDownloadButton = false;
- ErrorTitle = null;
- ErrorDetails = null;
- }
-
- private void DismissErrorButton_Click(object sender, RoutedEventArgs e)
- {
- ClearError();
- }
-
- private async void DownloadFFmpegButton_Click(object sender, RoutedEventArgs e)
- {
- ClearError();
- IsLoading = true;
-
- try
- {
- await PackageManager.DownloadAndExtractFFmpeg();
- var loaded = TryLoadFFmpeg();
-
- if (loaded)
- {
- // Initialize player controller now that FFmpeg is available
- _playerController = new VideoPlayerController(MediaElement);
- _playerController.PropertyChanged += PlayerController_PropertyChanged;
- _playerController.PlaybackSpeed = SelectedPlaybackSpeed;
-
- MediaElement.MediaOpened += MediaElement_MediaOpened;
- MediaElement.MediaEnded += MediaElement_MediaEnded;
- MediaElement.MediaFailed += MediaElement_MediaFailed;
-
- LoadClips(CamStorage.FindCommonRoots());
- }
- else
- {
- ShowError("FFmpeg Installation Failed", "FFmpeg was downloaded but could not be loaded. Please check the logs and try restarting the application.");
- }
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Failed to download FFmpeg");
- ShowError("Download Failed", $"Failed to download FFmpeg:\n\n{ex.Message}\n\nPlease check your internet connection and try again.");
- }
- finally
- {
- IsLoading = false;
- }
- }
-
- #endregion
-
- #region INotifyPropertyChanged
-
- public event PropertyChangedEventHandler PropertyChanged;
-
- private void OnPropertyChanged([CallerMemberName] string propertyName = null)
- {
- PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
- }
-
- private bool SetProperty(ref T field, T value, [CallerMemberName] string propertyName = null)
- {
- if (EqualityComparer.Default.Equals(field, value))
- return false;
-
- field = value;
- OnPropertyChanged(propertyName);
- return true;
- }
-
- #endregion
-}
diff --git a/TeslaCam/PackageManager.cs b/TeslaCam/PackageManager.cs
deleted file mode 100644
index 42e9ecf..0000000
--- a/TeslaCam/PackageManager.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-using System.IO;
-using System.IO.Compression;
-using System.Net.Http;
-using System.Runtime.InteropServices;
-using Serilog;
-
-namespace TeslaCam;
-
-public static class PackageManager
-{
- private static async Task DownloadFile(string url, string savePath)
- {
- using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
- var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
- response.EnsureSuccessStatusCode();
-
- using var fileStream = File.Create(savePath);
- await response.Content.CopyToAsync(fileStream);
- }
-
- private static void ExtractZipFile(string zipFilePath, string extractPath)
- {
- // Extract and flatten nested directories - the zip contains a bin directory with ffmpeg.exe and required DLLs
- using var archive = ZipFile.OpenRead(zipFilePath);
-
- foreach (var entry in archive.Entries)
- {
- if (string.IsNullOrEmpty(entry.Name))
- continue;
-
- // Find bin directory in the zip structure
- var entryPath = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
- var binIndex = entryPath.IndexOf("bin" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
-
- if (binIndex >= 0)
- {
- // Extract files from bin directory to root of extractPath
- var relativePath = entryPath.Substring(binIndex + 4); // Skip "bin\\"
- var destPath = Path.Combine(extractPath, relativePath);
-
- Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
- entry.ExtractToFile(destPath, overwrite: true);
- }
- }
- }
-
- public static async Task DownloadAndExtractFFmpeg()
- {
- var outputFolder = Path.GetFullPath("ffmpeg");
- var url = GetFFmpegDownloadUrl();
- var tempPath = Path.GetTempFileName();
-
- try
- {
- Log.Information("Downloading FFmpeg...");
-
- Log.Debug($"Downloading FFmpeg to {tempPath} from {url}");
- await DownloadFile(url, tempPath);
-
- Log.Information("Extracting FFmpeg...");
- Log.Debug($"Extracting FFmpeg to {outputFolder}");
-
- if (Directory.Exists(outputFolder))
- Directory.Delete(outputFolder, true);
- Directory.CreateDirectory(outputFolder);
-
- ExtractZipFile(tempPath, outputFolder);
-
- Log.Information("FFmpeg downloaded and extracted successfully");
- }
- finally
- {
- if (File.Exists(tempPath))
- File.Delete(tempPath);
- }
- }
-
- private static string GetFFmpegDownloadUrl()
- {
- var architecture = RuntimeInformation.ProcessArchitecture;
-
- return architecture switch
- {
- Architecture.Arm64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-winarm64-gpl-shared.zip",
- Architecture.X64 => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip",
- _ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip", // Default to x64
- };
- }
-
- public static IEnumerable FindFFmpegDirectories(string searchDirectory = ".")
- {
- foreach (var path in Directory.EnumerateFiles(searchDirectory, "ffmpeg.exe", SearchOption.AllDirectories))
- {
- yield return Path.GetFullPath(Path.GetDirectoryName(path));
- }
- }
-}
From 1926a684c544bee825ca0358d7a4fe706c2f33b6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 22:09:03 +0000
Subject: [PATCH 5/6] Fix FFmpeg extraction to flatten bin directory structure
Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com>
---
SentryReplay/PackageManager.cs | 64 ++++++++++++++++++++++++++--------
1 file changed, 49 insertions(+), 15 deletions(-)
diff --git a/SentryReplay/PackageManager.cs b/SentryReplay/PackageManager.cs
index 2601bb4..2ddf950 100644
--- a/SentryReplay/PackageManager.cs
+++ b/SentryReplay/PackageManager.cs
@@ -10,18 +10,38 @@ public static class PackageManager
{
private static async Task DownloadFile(string url, string savePath)
{
- using var client = new HttpClient();
- var response = await client.GetAsync(url);
- if (response.IsSuccessStatusCode)
- {
- using var fileStream = File.Create(savePath);
- await response.Content.CopyToAsync(fileStream);
- }
+ using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
+ var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
+ response.EnsureSuccessStatusCode();
+
+ using var fileStream = File.Create(savePath);
+ await response.Content.CopyToAsync(fileStream);
}
private static void ExtractZipFile(string zipFilePath, string extractPath)
{
- ZipFile.ExtractToDirectory(zipFilePath, extractPath, true);
+ // Extract and flatten nested directories - the zip contains a bin directory with ffmpeg.exe and required DLLs
+ using var archive = ZipFile.OpenRead(zipFilePath);
+
+ foreach (var entry in archive.Entries)
+ {
+ if (string.IsNullOrEmpty(entry.Name))
+ continue;
+
+ // Find bin directory in the zip structure
+ var entryPath = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
+ var binIndex = entryPath.IndexOf("bin" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+
+ if (binIndex >= 0)
+ {
+ // Extract files from bin directory to root of extractPath
+ var relativePath = entryPath.Substring(binIndex + 4); // Skip "bin\\"
+ var destPath = Path.Combine(extractPath, relativePath);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
+ entry.ExtractToFile(destPath, overwrite: true);
+ }
+ }
}
public static async Task DownloadAndExtractFFmpeg()
@@ -30,15 +50,29 @@ public static async Task DownloadAndExtractFFmpeg()
var url = GetFFmpegDownloadUrl();
var tempPath = Path.GetTempFileName();
- Log.Information("Getting ffmpeg");
-
- Log.Debug($"Downloading ffmpeg to {tempPath} from {url}");
- await DownloadFile(url, tempPath);
+ try
+ {
+ Log.Information("Downloading FFmpeg...");
- Log.Debug($"Extracting ffmpeg to {outputFolder}");
- ExtractZipFile(tempPath, outputFolder);
+ Log.Debug($"Downloading FFmpeg to {tempPath} from {url}");
+ await DownloadFile(url, tempPath);
- File.Delete(tempPath);
+ Log.Information("Extracting FFmpeg...");
+ Log.Debug($"Extracting FFmpeg to {outputFolder}");
+
+ if (Directory.Exists(outputFolder))
+ Directory.Delete(outputFolder, true);
+ Directory.CreateDirectory(outputFolder);
+
+ ExtractZipFile(tempPath, outputFolder);
+
+ Log.Information("FFmpeg downloaded and extracted successfully");
+ }
+ finally
+ {
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
+ }
}
private static string GetFFmpegDownloadUrl()
From 60ca3f962f6480bd62a15dbce363d93cfc9e3e34 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 22:10:30 +0000
Subject: [PATCH 6/6] Replace magic number with explicit calculation for bin
directory length
Co-authored-by: danielchalmers <7112040+danielchalmers@users.noreply.github.com>
---
SentryReplay/PackageManager.cs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/SentryReplay/PackageManager.cs b/SentryReplay/PackageManager.cs
index 2ddf950..d07b4e3 100644
--- a/SentryReplay/PackageManager.cs
+++ b/SentryReplay/PackageManager.cs
@@ -30,12 +30,13 @@ private static void ExtractZipFile(string zipFilePath, string extractPath)
// Find bin directory in the zip structure
var entryPath = entry.FullName.Replace('/', Path.DirectorySeparatorChar);
- var binIndex = entryPath.IndexOf("bin" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ var binDirectory = "bin" + Path.DirectorySeparatorChar;
+ var binIndex = entryPath.IndexOf(binDirectory, StringComparison.OrdinalIgnoreCase);
if (binIndex >= 0)
{
// Extract files from bin directory to root of extractPath
- var relativePath = entryPath.Substring(binIndex + 4); // Skip "bin\\"
+ var relativePath = entryPath.Substring(binIndex + binDirectory.Length);
var destPath = Path.Combine(extractPath, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);