diff --git a/SentryReplay.Tests/MainWindowViewModelTests.cs b/SentryReplay.Tests/MainWindowViewModelTests.cs new file mode 100644 index 0000000..7e4e8d3 --- /dev/null +++ b/SentryReplay.Tests/MainWindowViewModelTests.cs @@ -0,0 +1,278 @@ +using System.Windows.Input; + +namespace SentryReplay.Tests; + +public sealed class MainWindowViewModelTests +{ + // The view-model never invokes the controller factory in these tests; playback paths + // require FFmpeg/Flyleaf and are covered separately via VideoPlayerController. + private static MainWindowViewModel CreateViewModel() => new(() => null!); + + [Fact] + public void NewViewModel_DefaultsToFrontCamera_AndEmptyOverlay() + { + var vm = CreateViewModel(); + + vm.SelectedCameraView.ShouldBe(MainWindowViewModel.FrontCameraView); + vm.IsFrontViewSelected.ShouldBeTrue(); + vm.IsGridViewSelected.ShouldBeFalse(); + vm.IsSingleCameraViewSelected.ShouldBeTrue(); + vm.ShowMainContent.ShouldBeTrue(); + vm.ShowAboutPage.ShouldBeFalse(); + + // No clip selected, not loading, no error -> show the empty overlay, hide the video. + vm.HasNoClipSelected.ShouldBeTrue(); + vm.ShowStatusOverlay.ShouldBeTrue(); + vm.ShowVideoHosts.ShouldBeFalse(); + vm.PlayPauseIcon.ShouldBe("▶"); + } + + [Theory] + [InlineData("grid", "Grid")] + [InlineData("front", "Front")] + [InlineData("rear", "Rear")] + [InlineData("left", "Left")] + [InlineData("right", "Right")] + [InlineData("unrecognized", "Front")] // unknown values fall back to the front camera + public void SelectCameraView_SetsSelectedViewAndLabel(string cameraView, string expectedLabel) + { + var vm = CreateViewModel(); + + vm.SelectCameraViewCommand.Execute(cameraView); + + var expectedView = expectedLabel == "Front" + ? MainWindowViewModel.FrontCameraView + : cameraView; + vm.SelectedCameraView.ShouldBe(expectedView); + vm.ActiveCameraLabel.ShouldBe(expectedLabel); + } + + [Fact] + public void SelectCameraView_Grid_SetsGridFlags() + { + var vm = CreateViewModel(); + + vm.SelectCameraViewCommand.Execute("grid"); + + vm.IsGridViewSelected.ShouldBeTrue(); + vm.IsSingleCameraViewSelected.ShouldBeFalse(); + } + + [Fact] + public void SelectCameraView_Rear_SetsSingleViewFlags() + { + var vm = CreateViewModel(); + + vm.SelectCameraViewCommand.Execute("rear"); + + vm.IsRearViewSelected.ShouldBeTrue(); + vm.IsFrontViewSelected.ShouldBeFalse(); + vm.IsGridViewSelected.ShouldBeFalse(); + vm.IsSingleCameraViewSelected.ShouldBeTrue(); + } + + [Fact] + public void SelectCameraView_RaisesPropertyChangedForSelectedCameraView() + { + // The view re-parents the Flyleaf hosts when SelectedCameraView changes, + // so this notification is part of the view/view-model contract. + var vm = CreateViewModel(); + var changed = new List(); + vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + vm.SelectCameraViewCommand.Execute("grid"); + + changed.ShouldContain(nameof(MainWindowViewModel.SelectedCameraView)); + } + + [Fact] + public void ToggleAbout_FlipsAboutPageAndMainContent() + { + var vm = CreateViewModel(); + + vm.ToggleAboutCommand.Execute(null); + vm.ShowAboutPage.ShouldBeTrue(); + vm.ShowMainContent.ShouldBeFalse(); + + vm.ToggleAboutCommand.Execute(null); + vm.ShowAboutPage.ShouldBeFalse(); + vm.ShowMainContent.ShouldBeTrue(); + } + + [Fact] + public void Loading_ShowsStatusOverlay_AndHidesVideo() + { + var vm = CreateViewModel(); + + vm.IsLoading = true; + + vm.ShowStatusOverlay.ShouldBeTrue(); + vm.ShowVideoHosts.ShouldBeFalse(); + vm.IsIndeterminateProgress.ShouldBeTrue(); + } + + [Fact] + public void Error_ShowsStatusOverlay_AndReportsError() + { + var vm = CreateViewModel(); + + vm.ShowErrorOverlay = true; + + vm.HasError.ShouldBeTrue(); + vm.ShowStatusOverlay.ShouldBeTrue(); + vm.ShowVideoHosts.ShouldBeFalse(); + vm.HasNoClipSelected.ShouldBeFalse(); + } + + [Fact] + public void SelectingClip_HidesOverlay_AndShowsVideo() + { + var vm = CreateViewModel(); + + vm.SelectedClip = TestClips.Create(1)[0]; + + vm.HasNoClipSelected.ShouldBeFalse(); + vm.ShowStatusOverlay.ShouldBeFalse(); + vm.ShowVideoHosts.ShouldBeTrue(); + } + + [Fact] + public void CanPlayPause_RequiresClipOrPlayback_AndNotLoading() + { + var vm = CreateViewModel(); + vm.CanPlayPause.ShouldBeFalse(); + + vm.SelectedClip = TestClips.Create(1)[0]; + vm.CanPlayPause.ShouldBeTrue(); + + vm.IsLoading = true; + vm.CanPlayPause.ShouldBeFalse(); + + // Even with no selected clip, an in-flight playback keeps the toggle live. + vm.IsLoading = false; + vm.SelectedClip = null; + vm.IsPlaying = true; + vm.CanPlayPause.ShouldBeTrue(); + } + + [Fact] + public void CanStop_WhenPlayingOrLoading() + { + var vm = CreateViewModel(); + vm.CanStop.ShouldBeFalse(); + + vm.IsPlaying = true; + vm.CanStop.ShouldBeTrue(); + + vm.IsPlaying = false; + vm.IsLoading = true; + vm.CanStop.ShouldBeTrue(); + } + + [Theory] + [InlineData(false, "▶")] + [InlineData(true, "⏸")] + public void PlayPauseIcon_ReflectsPlaybackState(bool isPlaying, string expectedIcon) + { + var vm = CreateViewModel(); + + vm.IsPlaying = isPlaying; + + vm.PlayPauseIcon.ShouldBe(expectedIcon); + } + + [Fact] + public void LoadingStatusText_ShowsRenderProgressWhileRendering() + { + var vm = CreateViewModel(); + vm.IsLoading = true; + + vm.LoadingStatusText.ShouldBe("Loading..."); + vm.IsIndeterminateProgress.ShouldBeTrue(); + + vm.IsRendering = true; + vm.RenderProgress = 0.5; + + vm.RenderProgressPercent.ShouldBe(50); + vm.LoadingStatusText.ShouldBe("Rendering... 50%"); + // A determinate render progress bar replaces the indeterminate spinner. + vm.IsIndeterminateProgress.ShouldBeFalse(); + } + + [Fact] + public void UpdateBadge_DefaultsToUpToDate() + { + var vm = CreateViewModel(); + + vm.IsUpdateAvailable.ShouldBeFalse(); + vm.HasUpdateBadge.ShouldBeFalse(); + vm.UpdateStatusTitle.ShouldBe("You're up to date"); + vm.UpdateStatusDetails.ShouldBe("No newer release was found."); + vm.LatestVersionText.ShouldBe("Unknown"); + vm.LatestReleaseUrl.ShouldBe(UpdateService.ReleasesPageUrl); + } + + [Fact] + public void UpdateBadge_ReflectsAvailableRelease() + { + var vm = CreateViewModel(); + + vm.LatestRelease = new UpdateRelease(new Version(1, 4, 2), "v1.4.2", "https://example.com/releases/1.4.2"); + vm.IsUpdateAvailable = true; + + vm.HasUpdateBadge.ShouldBeTrue(); + vm.UpdateStatusTitle.ShouldBe("Update available"); + vm.LatestVersionText.ShouldBe("1.4.2"); + vm.UpdateStatusDetails.ShouldBe("Version 1.4.2 is available."); + vm.LatestReleaseUrl.ShouldBe("https://example.com/releases/1.4.2"); + } + + [Theory] + [InlineData(Key.F, ModifierKeys.Control)] + [InlineData(Key.F3, ModifierKeys.None)] + public async Task SearchShortcut_RequestsFocus_ClosesAbout_AndIsHandled(Key key, ModifierKeys modifiers) + { + var vm = CreateViewModel(); + vm.ShowAboutPage = true; + var focusRequests = 0; + vm.SearchBoxFocusRequested += (_, _) => focusRequests++; + + var handled = await vm.HandleKeyDownAsync(key, modifiers); + + handled.ShouldBeTrue(); + vm.ShowAboutPage.ShouldBeFalse(); + focusRequests.ShouldBe(1); + } + + [Theory] + [InlineData(Key.Space, ModifierKeys.None)] + [InlineData(Key.Left, ModifierKeys.None)] + [InlineData(Key.A, ModifierKeys.None)] + public async Task UnhandledKeys_WithoutPlayer_ReturnFalse(Key key, ModifierKeys modifiers) + { + var vm = CreateViewModel(); + + var handled = await vm.HandleKeyDownAsync(key, modifiers); + + handled.ShouldBeFalse(); + } + + [Fact] + public void DismissError_ClearsErrorState() + { + var vm = CreateViewModel(); + vm.ShowErrorOverlay = true; + vm.ShowFFmpegDownloadButton = true; + vm.CanDismissError = false; + vm.ErrorTitle = "Boom"; + vm.ErrorDetails = "Something went wrong"; + + vm.DismissErrorCommand.Execute(null); + + vm.ShowErrorOverlay.ShouldBeFalse(); + vm.ShowFFmpegDownloadButton.ShouldBeFalse(); + vm.CanDismissError.ShouldBeTrue(); + vm.ErrorTitle.ShouldBeNull(); + vm.ErrorDetails.ShouldBeNull(); + } +} diff --git a/SentryReplay/MainWindow.xaml b/SentryReplay/MainWindow.xaml index e8ea758..977c49a 100644 --- a/SentryReplay/MainWindow.xaml +++ b/SentryReplay/MainWindow.xaml @@ -8,7 +8,7 @@ Title="Sentry Replay" MinWidth="1200" MinHeight="700" - d:DataContext="{d:DesignInstance Type=local:MainWindow, IsDesignTimeCreatable=False}" + d:DataContext="{d:DesignInstance Type=local:MainWindowViewModel, IsDesignTimeCreatable=False}" Background="{DynamicResource SystemFillColorSolidNeutralBackgroundBrush}" Closing="Window_Closing" ContentRendered="Window_ContentRendered" diff --git a/SentryReplay/MainWindow.xaml.cs b/SentryReplay/MainWindow.xaml.cs index 9cc77de..e4f67de 100644 --- a/SentryReplay/MainWindow.xaml.cs +++ b/SentryReplay/MainWindow.xaml.cs @@ -1,252 +1,41 @@ using System.ComponentModel; using System.Diagnostics; -using System.IO; -using System.Reflection; -using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Navigation; -using System.Windows.Threading; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; using FlyleafLib.Controls.WPF; -using Microsoft.Win32; using Serilog; namespace SentryReplay; /// -/// Main WPF window, player state, and Flyleaf host layout. +/// Main WPF window. Owns only view concerns: window lifecycle, Flyleaf host layout, the seek +/// slider input plumbing, and search-box focus. All state, commands, and orchestration live in +/// . /// -[INotifyPropertyChanged] public partial class MainWindow : Window { - private const string GridCameraView = "grid"; - private const string FrontCameraView = "front"; - private const string RearCameraView = "rear"; - private const string LeftCameraView = "left"; - private const string RightCameraView = "right"; - - private readonly List _allClips = []; - private readonly FlyleafRuntime _flyleafRuntime = new(); - private readonly UpdateService _updateService = new(); - private VideoPlayerController _playerController; - private bool _isSeeking; - private bool _isInitialized; + private readonly MainWindowViewModel _viewModel; private bool _isClosing; private bool _isReadyToClose; public MainWindow() { InitializeComponent(); - DataContext = this; - UpdateCameraHostLayout(); - } - - public bool ShowMainContent => !ShowAboutPage; - - public Version CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0); - - public string FileVersion => FormatVersion(CurrentVersion); - - public string RuntimeDescription => $"{RuntimeInformation.FrameworkDescription} ({RuntimeInformation.ProcessArchitecture})"; - - public string OsDescription => RuntimeInformation.OSDescription; - - public string ExecutablePath => Environment.ProcessPath; - - public bool HasUpdateBadge => IsUpdateAvailable; - public string LatestVersionText => LatestRelease is null ? "Unknown" : FormatVersion(LatestRelease.Version); + _viewModel = new MainWindowViewModel( + () => VideoPlayerController.Create(FrontFlyleafHost, BackFlyleafHost, LeftFlyleafHost, RightFlyleafHost)); + _viewModel.SearchBoxFocusRequested += OnSearchBoxFocusRequested; + _viewModel.PropertyChanged += ViewModelOnPropertyChanged; - public string LatestReleaseUrl => LatestRelease?.ReleaseUrl ?? UpdateService.ReleasesPageUrl; - - public string ReleasesPageUrl => UpdateService.ReleasesPageUrl; - - public string UpdateStatusTitle => IsUpdateAvailable - ? "Update available" - : "You're up to date"; - - public string UpdateStatusDetails => IsUpdateAvailable - ? $"Version {LatestVersionText} is available." - : "No newer release was found."; - - public IReadOnlyList PlaybackSpeedOptions { get; } = - [ - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - 2.0, - 3.0, - 4.0, - ]; - - 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 string PositionText - { - get - { - var duration = _playerController?.Duration ?? TimeSpan.Zero; - var position = TimeSpan.FromSeconds(SeekPosition * duration.TotalSeconds); - return FormatTimeSpan(position); - } + DataContext = _viewModel; + UpdateCameraHostLayout(); } - public string DurationText => FormatTimeSpan(_playerController?.Duration ?? TimeSpan.Zero); - - public bool CanSeek => _playerController?.IsMediaOpen == true && !IsLoading && _playerController.Duration > TimeSpan.Zero; - - public bool CanPlayPause => (SelectedClip is not null || IsPlaying) && !IsLoading; - - public bool CanStop => IsPlaying || IsLoading; - - public bool CanGoNext => _playerController?.CanGoNext == true; - - public bool CanGoPrevious => _playerController?.CanGoPrevious == true; - - public string PlayPauseIcon => IsPlaying ? "\u23F8" : "\u25B6"; - - public bool ShowStatusOverlay => IsLoading || ShowErrorOverlay || HasNoClipSelected; - - public bool ShowVideoHosts => !ShowStatusOverlay; - - public bool HasError => ShowErrorOverlay; - - public bool HasNoClipSelected => SelectedClip is null && !IsLoading && !ShowErrorOverlay; - - public bool IsIndeterminateProgress => IsLoading && !IsRendering; - - public bool IsGridViewSelected => SelectedCameraView == GridCameraView; - - public bool IsSingleCameraViewSelected => !IsGridViewSelected; - - public bool IsFrontViewSelected => SelectedCameraView == FrontCameraView; - - public bool IsRearViewSelected => SelectedCameraView == RearCameraView; - - public bool IsLeftViewSelected => SelectedCameraView == LeftCameraView; - - public bool IsRightViewSelected => SelectedCameraView == RightCameraView; - - public string ActiveCameraLabel => SelectedCameraView switch - { - GridCameraView => "Grid", - RearCameraView => "Rear", - LeftCameraView => "Left", - RightCameraView => "Right", - _ => "Front", - }; - - public string LoadingStatusText => IsRendering - ? $"Rendering... {RenderProgressPercent}%" - : "Loading..."; - - public int RenderProgressPercent => (int)(RenderProgress * 100); - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(FilteredClips))] - private string _filterText = string.Empty; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(HasNoClipSelected))] - [NotifyPropertyChangedFor(nameof(ShowStatusOverlay))] - [NotifyPropertyChangedFor(nameof(ShowVideoHosts))] - [NotifyPropertyChangedFor(nameof(CanPlayPause))] - private CamClip _selectedClip; - - [ObservableProperty] - private string _errorTitle; - - [ObservableProperty] - private string _errorDetails; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowStatusOverlay))] - [NotifyPropertyChangedFor(nameof(ShowVideoHosts))] - [NotifyPropertyChangedFor(nameof(HasNoClipSelected))] - [NotifyPropertyChangedFor(nameof(HasError))] - private bool _showErrorOverlay; - - [ObservableProperty] - private bool _canDismissError = true; - - [ObservableProperty] - private bool _showFFmpegDownloadButton; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowMainContent))] - private bool _showAboutPage; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(CanPlayPause))] - [NotifyPropertyChangedFor(nameof(CanStop))] - [NotifyPropertyChangedFor(nameof(LoadingStatusText))] - [NotifyPropertyChangedFor(nameof(IsIndeterminateProgress))] - [NotifyPropertyChangedFor(nameof(ShowStatusOverlay))] - [NotifyPropertyChangedFor(nameof(ShowVideoHosts))] - [NotifyPropertyChangedFor(nameof(HasNoClipSelected))] - [NotifyPropertyChangedFor(nameof(CanSeek))] - private bool _isLoading; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(LoadingStatusText))] - [NotifyPropertyChangedFor(nameof(IsIndeterminateProgress))] - private bool _isRendering; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(RenderProgressPercent))] - [NotifyPropertyChangedFor(nameof(LoadingStatusText))] - private double _renderProgress; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(PositionText))] - private double _seekPosition; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(PlayPauseIcon))] - [NotifyPropertyChangedFor(nameof(CanPlayPause))] - [NotifyPropertyChangedFor(nameof(CanStop))] - private bool _isPlaying; - - [ObservableProperty] - private double _selectedPlaybackSpeed = 1.0; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(IsGridViewSelected))] - [NotifyPropertyChangedFor(nameof(IsSingleCameraViewSelected))] - [NotifyPropertyChangedFor(nameof(IsFrontViewSelected))] - [NotifyPropertyChangedFor(nameof(IsRearViewSelected))] - [NotifyPropertyChangedFor(nameof(IsLeftViewSelected))] - [NotifyPropertyChangedFor(nameof(IsRightViewSelected))] - [NotifyPropertyChangedFor(nameof(ActiveCameraLabel))] - private string _selectedCameraView = FrontCameraView; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(HasUpdateBadge))] - [NotifyPropertyChangedFor(nameof(UpdateStatusTitle))] - [NotifyPropertyChangedFor(nameof(UpdateStatusDetails))] - private bool _isUpdateAvailable; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(LatestVersionText))] - [NotifyPropertyChangedFor(nameof(LatestReleaseUrl))] - [NotifyPropertyChangedFor(nameof(UpdateStatusDetails))] - private UpdateRelease _latestRelease; - private async void Window_ContentRendered(object sender, EventArgs e) { - await InitializeAsync(); + await _viewModel.InitializeAsync(); } private void Window_Closing(object sender, CancelEventArgs e) @@ -274,7 +63,7 @@ private void CloseAfterShutdown() { try { - Shutdown(); + _viewModel.Shutdown(); } catch (Exception ex) { @@ -289,7 +78,7 @@ private void CloseAfterShutdown() private async void Window_KeyDown(object sender, KeyEventArgs e) { - if (await HandleKeyDownAsync(e.Key, Keyboard.Modifiers)) + if (await _viewModel.HandleKeyDownAsync(e.Key, Keyboard.Modifiers)) { e.Handled = true; } @@ -297,498 +86,33 @@ private async void Window_KeyDown(object sender, KeyEventArgs e) private void SeekSlider_PreviewMouseDown(object sender, MouseButtonEventArgs e) { - BeginSeek(); + _viewModel.BeginSeek(); } private async void SeekSlider_PreviewMouseUp(object sender, MouseButtonEventArgs e) { - await EndSeekAsync(); + await _viewModel.EndSeekAsync(); } private void SeekSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) { - if (_isSeeking) - { - OnPropertyChanged(nameof(PositionText)); - } - } - - private async Task InitializeAsync() - { - if (_isInitialized) - return; - - _isInitialized = true; - Log.Debug("Initializing main window"); - -#if DEBUG - Log.Debug("Skipping update check in debug build"); -#else - _ = UpdateLatestReleaseAsync(); -#endif - - if (_flyleafRuntime.TryStart()) - { - InitializePlayer(); - LoadClips(CamStorage.FindCommonRoots()); - } - else - { - ShowFFmpegMissingError(); - } - } - - private void Shutdown() - { - var controller = _playerController; - _playerController = null; - - if (controller is null) - return; - - controller.PropertyChanged -= PlayerControllerOnPropertyChanged; - controller.Dispose(); - } - - private void InitializePlayer() - { - if (_playerController is not null) - return; - - _playerController = VideoPlayerController.Create(FrontFlyleafHost, BackFlyleafHost, LeftFlyleafHost, RightFlyleafHost); - _playerController.PropertyChanged += PlayerControllerOnPropertyChanged; - _playerController.PlaybackSpeed = SelectedPlaybackSpeed; - } - - private void LoadClips(IEnumerable roots) - { - ClearError(); - _allClips.Clear(); - SelectedClip = null; - - var rootList = roots?.Where(root => !string.IsNullOrWhiteSpace(root)).ToList() ?? []; - if (rootList.Count == 0) - { - Log.Information("No dashcam roots found"); - ShowError( - "No Dashcam Folders Found", - "Click 'Select Folder' to choose a folder containing Tesla dashcam footage (TeslaCam folder).", - canDismiss: true); - RefreshClipState(); - return; - } - - Log.Information("Loading dashcam clips. RootCount={RootCount}; Roots={Roots}", rootList.Count, rootList); - var totalStopwatch = Stopwatch.StartNew(); - var failedRoots = 0; - - foreach (var root in rootList) - { - var rootStopwatch = Stopwatch.StartNew(); - Log.Debug("Scanning dashcam root. Root={Root}", root); - - try - { - var storage = CamStorage.Map(root); - _allClips.AddRange(storage.Clips); - Log.Information( - "Scanned dashcam root. Root={Root}; ClipCount={ClipCount}; ElapsedMs={ElapsedMs}", - root, - storage.Clips.Count, - rootStopwatch.ElapsedMilliseconds); - } - catch (UnauthorizedAccessException ex) - { - failedRoots++; - Log.Error(ex, "Access denied while loading dashcam root. Root={Root}", root); - ShowError("Access Denied", $"Cannot access folder: {root}\n\nCheck that you have permission to read this location."); - } - catch (Exception ex) - { - failedRoots++; - Log.Error(ex, "Failed to load dashcam root. Root={Root}", root); - ShowError("Error Loading Clips", $"Failed to load clips from:\n{root}\n\nError: {ex.Message}"); - } - } - - _playerController?.LoadClips(_allClips); - RefreshClipState(); - Log.Information( - "Finished loading dashcam clips. ClipCount={ClipCount}; RootCount={RootCount}; FailedRootCount={FailedRootCount}; ElapsedMs={ElapsedMs}", - _allClips.Count, - rootList.Count, - failedRoots, - totalStopwatch.ElapsedMilliseconds); - } - - private void RefreshClipState() - { - OnPropertyChanged(nameof(FilteredClips)); - OnPropertyChanged(nameof(HasNoClipSelected)); - OnPropertyChanged(nameof(ShowStatusOverlay)); - OnPropertyChanged(nameof(ShowVideoHosts)); - OnPropertyChanged(nameof(CanPlayPause)); - OnPropertyChanged(nameof(CanGoNext)); - OnPropertyChanged(nameof(CanGoPrevious)); - } - - [RelayCommand] - private async Task OpenFolderAsync() - { - Log.Debug("Opening folder picker"); - - var dialog = new OpenFolderDialog - { - Multiselect = true, - Title = "Select a folder containing Tesla dashcam footage (TeslaCam folder)", - }; - - if (dialog.ShowDialog() == true) - { - Log.Information( - "User selected dashcam folders. FolderCount={FolderCount}; Folders={Folders}", - dialog.FolderNames.Length, - dialog.FolderNames); - - if (_playerController is not null) - { - await _playerController.StopAsync(); - } - - LoadClips(dialog.FolderNames); - } - else - { - Log.Debug("Folder picker canceled"); - } - } - - [RelayCommand] - private async Task PlayPauseAsync() - { - if (_playerController is not null) - { - await _playerController.TogglePlayPauseAsync(); - } - } - - [RelayCommand] - private async Task StopAsync() - { - if (_playerController is null) - return; - - await _playerController.StopAsync(); - SeekPosition = 0; - } - - [RelayCommand] - private async Task PreviousAsync() - { - if (_playerController is null) - return; - - await _playerController.PreviousAsync(); - SelectedClip = _playerController.CurrentClip; - } - - [RelayCommand] - private async Task NextAsync() - { - if (_playerController is null) - return; - - await _playerController.NextAsync(); - SelectedClip = _playerController.CurrentClip; - } - - [RelayCommand] - private async Task DownloadFFmpegAsync() - { - IsLoading = true; - ClearError(); - - try - { - Log.Debug("Starting FFmpeg download workflow"); - await PackageManager.DownloadAndExtractFFmpeg(); - if (_flyleafRuntime.TryStart()) - { - InitializePlayer(); - LoadClips(CamStorage.FindCommonRoots()); - } - else - { - ShowFFmpegMissingError(); - } - } - catch (Exception ex) - { - Log.Error(ex, "Failed to download FFmpeg"); - ShowError("Download Failed", $"Failed to download FFmpeg: {ex.Message}"); - ShowFFmpegDownloadButton = true; - } - finally - { - IsLoading = false; - } + _viewModel.OnSeekSliderValueChanged(); } - private async Task PlaySelectedClipAsync() + private void ViewModelOnPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (SelectedClip is null || _playerController is null) - return; - - ClearError(); - IsLoading = true; - await Dispatcher.Yield(DispatcherPriority.Background); - - try + if (e.PropertyName == nameof(MainWindowViewModel.SelectedCameraView)) { - await _playerController.GoToClipAsync(SelectedClip); - } - catch (Exception ex) - { - IsLoading = false; - Log.Error( - ex, - "Failed to play selected clip. ClipName={ClipName}; ClipPath={ClipPath}", - SelectedClip.Name, - SelectedClip.FullPath); - ShowError("Playback Failed", $"Could not play clip: {SelectedClip.Name}\n\nError: {ex.Message}"); + UpdateCameraHostLayout(); } } - private async Task HandleKeyDownAsync(Key key, ModifierKeys modifiers) + private void OnSearchBoxFocusRequested(object sender, EventArgs e) { - if ((key == Key.F && modifiers == ModifierKeys.Control) || - (key == Key.F3 && modifiers == ModifierKeys.None)) - { - FocusSearchBox(); - return true; - } - - if (_playerController is null) - { - return false; - } - - switch (key) - { - case Key.Space: - await _playerController.TogglePlayPauseAsync(); - return true; - - case Key.Left: - if (modifiers == ModifierKeys.Control && CanGoPrevious) - { - await _playerController.PreviousAsync(); - SelectedClip = _playerController.CurrentClip; - } - else if (CanSeek) - { - var position = _playerController.Position - TimeSpan.FromSeconds(5); - await _playerController.SeekAsync(position < TimeSpan.Zero ? TimeSpan.Zero : position); - } - - return true; - - case Key.Right: - if (modifiers == ModifierKeys.Control && CanGoNext) - { - await _playerController.NextAsync(); - SelectedClip = _playerController.CurrentClip; - } - else if (CanSeek) - { - var duration = _playerController.Duration; - var position = _playerController.Position + TimeSpan.FromSeconds(5); - await _playerController.SeekAsync(position > duration ? duration : position); - } - - return true; - } - - return false; - } - - private void FocusSearchBox() - { - ShowAboutPage = false; SearchBox.Focus(); SearchBox.SelectAll(); } - private void BeginSeek() - { - if (CanSeek) - { - _isSeeking = true; - } - } - - private async Task EndSeekAsync() - { - if (_playerController is null || !CanSeek) - { - _isSeeking = false; - return; - } - - await SeekToCurrentPositionAsync(); - _isSeeking = false; - } - - private async Task SeekToCurrentPositionAsync() - { - if (_playerController is null) - return; - - var duration = _playerController.Duration; - if (duration.TotalSeconds > 0) - { - var targetPosition = TimeSpan.FromSeconds(SeekPosition * duration.TotalSeconds); - await _playerController.SeekAsync(targetPosition); - } - } - - private void UpdateSeekPositionFromController() - { - if (_playerController is null || _isSeeking) - return; - - var duration = _playerController.Duration; - SeekPosition = duration.TotalSeconds > 0 - ? Math.Clamp(_playerController.Position.TotalSeconds / duration.TotalSeconds, 0, 1) - : 0; - } - - private void PlayerControllerOnPropertyChanged(object sender, PropertyChangedEventArgs e) - { - if (string.IsNullOrWhiteSpace(e.PropertyName)) - return; - - RunOnUiThread(() => HandlePlayerControllerPropertyChanged(e.PropertyName)); - } - - private void HandlePlayerControllerPropertyChanged(string propertyName) - { - if (_playerController is null) - return; - - switch (propertyName) - { - case nameof(VideoPlayerController.IsLoading): - IsLoading = _playerController.IsLoading; - break; - - case nameof(VideoPlayerController.IsPlaying): - IsPlaying = _playerController.IsPlaying; - break; - - case nameof(VideoPlayerController.Duration): - UpdateSeekPositionFromController(); - OnPropertyChanged(nameof(DurationText)); - OnPropertyChanged(nameof(PositionText)); - OnPropertyChanged(nameof(CanSeek)); - break; - - case nameof(VideoPlayerController.Position): - UpdateSeekPositionFromController(); - break; - - case nameof(VideoPlayerController.ErrorMessage): - if (_playerController.ErrorMessage is not null) - { - ShowError("Playback Error", _playerController.ErrorMessage); - } - - break; - - case nameof(VideoPlayerController.CurrentClip): - SelectedClip = _playerController.CurrentClip; - RefreshClipState(); - break; - - case nameof(VideoPlayerController.IsMediaOpen): - OnPropertyChanged(nameof(CanSeek)); - break; - } - } - - internal static string FormatTimeSpan(TimeSpan ts) - { - return ts.TotalHours >= 1 - ? ts.ToString(@"h\:mm\:ss") - : ts.ToString(@"m\:ss"); - } - - private static string FormatVersion(Version version) - { - if (version is null) - { - return "Unknown"; - } - - if (version.Revision >= 0) - { - return version.ToString(4); - } - - return version.Build >= 0 - ? version.ToString(3) - : version.ToString(2); - } - - private void ShowError(string title, string details, bool canDismiss = true) - { - ErrorTitle = title; - ErrorDetails = details; - CanDismissError = canDismiss; - ShowErrorOverlay = true; - } - - private void ClearError() - { - ShowErrorOverlay = false; - ShowFFmpegDownloadButton = false; - CanDismissError = true; - ErrorTitle = null; - ErrorDetails = null; - } - - [RelayCommand] - private void DismissError() - { - ClearError(); - } - - private void ShowFFmpegMissingError() - { - Log.Debug("Showing FFmpeg missing prompt"); - ShowFFmpegDownloadButton = true; - ShowError("FFmpeg Required", "FFmpeg is required to play clips. This will download about 80MB.", canDismiss: false); - } - - [RelayCommand] - private void ToggleAbout() - { - ShowAboutPage = !ShowAboutPage; - } - - [RelayCommand] - private void SelectCameraView(string cameraView) - { - SelectedCameraView = cameraView switch - { - GridCameraView => GridCameraView, - RearCameraView => RearCameraView, - LeftCameraView => LeftCameraView, - RightCameraView => RightCameraView, - _ => FrontCameraView, - }; - } - private void UpdateCameraHostLayout() { if (PrimaryCameraHostSlot is null) @@ -796,30 +120,30 @@ private void UpdateCameraHostLayout() ClearCameraHostSlots(); - switch (SelectedCameraView) + switch (_viewModel.SelectedCameraView) { - case GridCameraView: + case MainWindowViewModel.GridCameraView: MoveHostToSlot(FrontFlyleafHost, GridFrontHostSlot); MoveHostToSlot(BackFlyleafHost, GridRearHostSlot); MoveHostToSlot(LeftFlyleafHost, GridLeftHostSlot); MoveHostToSlot(RightFlyleafHost, GridRightHostSlot); break; - case RearCameraView: + case MainWindowViewModel.RearCameraView: MoveHostToSlot(BackFlyleafHost, PrimaryCameraHostSlot); MoveHostToSlot(FrontFlyleafHost, FrontTileHostSlot); MoveHostToSlot(LeftFlyleafHost, LeftTileHostSlot); MoveHostToSlot(RightFlyleafHost, RightTileHostSlot); break; - case LeftCameraView: + case MainWindowViewModel.LeftCameraView: MoveHostToSlot(LeftFlyleafHost, PrimaryCameraHostSlot); MoveHostToSlot(FrontFlyleafHost, FrontTileHostSlot); MoveHostToSlot(BackFlyleafHost, RearTileHostSlot); MoveHostToSlot(RightFlyleafHost, RightTileHostSlot); break; - case RightCameraView: + case MainWindowViewModel.RightCameraView: MoveHostToSlot(RightFlyleafHost, PrimaryCameraHostSlot); MoveHostToSlot(FrontFlyleafHost, FrontTileHostSlot); MoveHostToSlot(BackFlyleafHost, RearTileHostSlot); @@ -886,62 +210,6 @@ private static void RemoveHostFromParent(FlyleafHost host) } } - private async Task UpdateLatestReleaseAsync() - { - var result = await _updateService.CheckForUpdateAsync(CurrentVersion); - LatestRelease = result.LatestRelease; - IsUpdateAvailable = result.IsUpdateAvailable; - - Log.Information( - "Checked for updates. CurrentVersion={CurrentVersion}; LatestVersion={LatestVersion}; IsUpdateAvailable={IsUpdateAvailable}", - FormatVersion(CurrentVersion), - LatestVersionText, - IsUpdateAvailable); - } - - private static bool CanUseClip(CamClip clip) - { - return clip is not null; - } - - [RelayCommand(CanExecute = nameof(CanUseClip))] - private void OpenClipFolder(CamClip clip) - { - if (clip is null) - { - return; - } - - if (!Directory.Exists(clip.FullPath)) - { - ShowError("Clip Folder Not Found", $"Could not find folder:\n{clip.FullPath}"); - return; - } - - Process.Start(new ProcessStartInfo(clip.FullPath) - { - UseShellExecute = true, - }); - } - - [RelayCommand(CanExecute = nameof(CanUseClip))] - private void CopyClipPath(CamClip clip) - { - if (clip is not null) - { - Clipboard.SetText(clip.FullPath); - } - } - - [RelayCommand(CanExecute = nameof(CanUseClip))] - private void CopyClipName(CamClip clip) - { - if (clip is not null) - { - Clipboard.SetText(clip.Name); - } - } - private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e) { if (e.Uri is null) @@ -956,41 +224,4 @@ private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e e.Handled = true; } - - private void RunOnUiThread(Action action) - { - if (Dispatcher.CheckAccess()) - { - action(); - return; - } - - Dispatcher.Invoke(action); - } - - partial void OnSelectedClipChanged(CamClip value) - { - if (value is not null) - { - Log.Debug( - "Selected clip changed. ClipName={ClipName}; ClipPath={ClipPath}", - value.Name, - value.FullPath); - _ = PlaySelectedClipAsync(); - } - } - - partial void OnSelectedPlaybackSpeedChanged(double value) - { - if (_playerController is not null) - { - Log.Information("Playback speed changed. Speed={PlaybackSpeed}", value); - _playerController.PlaybackSpeed = value; - } - } - - partial void OnSelectedCameraViewChanged(string value) - { - UpdateCameraHostLayout(); - } } diff --git a/SentryReplay/MainWindowViewModel.cs b/SentryReplay/MainWindowViewModel.cs new file mode 100644 index 0000000..2c22502 --- /dev/null +++ b/SentryReplay/MainWindowViewModel.cs @@ -0,0 +1,815 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Win32; +using Serilog; + +namespace SentryReplay; + +/// +/// View-model for the main window: clip browsing, playback orchestration, update checks, +/// FFmpeg prompts, and shell actions. Holds no references to WPF controls; the view supplies +/// the playback controller (via ) +/// and reacts to and changes. +/// +public partial class MainWindowViewModel : ObservableObject +{ + public const string GridCameraView = "grid"; + public const string FrontCameraView = "front"; + public const string RearCameraView = "rear"; + public const string LeftCameraView = "left"; + public const string RightCameraView = "right"; + + private readonly List _allClips = []; + private readonly FlyleafRuntime _flyleafRuntime = new(); + private readonly UpdateService _updateService = new(); + private readonly Func _playerControllerFactory; + private readonly Dispatcher _dispatcher; + private VideoPlayerController _playerController; + private bool _isSeeking; + private bool _isInitialized; + + public MainWindowViewModel(Func playerControllerFactory) + { + _playerControllerFactory = playerControllerFactory; + _dispatcher = Dispatcher.CurrentDispatcher; + } + + /// + /// Raised when the view should move keyboard focus to the clip search box. + /// + public event EventHandler SearchBoxFocusRequested; + + public bool ShowMainContent => !ShowAboutPage; + + public Version CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0); + + public string FileVersion => FormatVersion(CurrentVersion); + + public string RuntimeDescription => $"{RuntimeInformation.FrameworkDescription} ({RuntimeInformation.ProcessArchitecture})"; + + public string OsDescription => RuntimeInformation.OSDescription; + + public string ExecutablePath => Environment.ProcessPath; + + public bool HasUpdateBadge => IsUpdateAvailable; + + public string LatestVersionText => LatestRelease is null ? "Unknown" : FormatVersion(LatestRelease.Version); + + public string LatestReleaseUrl => LatestRelease?.ReleaseUrl ?? UpdateService.ReleasesPageUrl; + + public string ReleasesPageUrl => UpdateService.ReleasesPageUrl; + + public string UpdateStatusTitle => IsUpdateAvailable + ? "Update available" + : "You're up to date"; + + public string UpdateStatusDetails => IsUpdateAvailable + ? $"Version {LatestVersionText} is available." + : "No newer release was found."; + + public IReadOnlyList PlaybackSpeedOptions { get; } = + [ + 0.25, + 0.5, + 0.75, + 1.0, + 1.25, + 1.5, + 2.0, + 3.0, + 4.0, + ]; + + 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 string PositionText + { + get + { + var duration = _playerController?.Duration ?? TimeSpan.Zero; + var position = TimeSpan.FromSeconds(SeekPosition * duration.TotalSeconds); + return FormatTimeSpan(position); + } + } + + public string DurationText => FormatTimeSpan(_playerController?.Duration ?? TimeSpan.Zero); + + public bool CanSeek => _playerController?.IsMediaOpen == true && !IsLoading && _playerController.Duration > TimeSpan.Zero; + + public bool CanPlayPause => (SelectedClip is not null || IsPlaying) && !IsLoading; + + public bool CanStop => IsPlaying || IsLoading; + + public bool CanGoNext => _playerController?.CanGoNext == true; + + public bool CanGoPrevious => _playerController?.CanGoPrevious == true; + + public string PlayPauseIcon => IsPlaying ? "⏸" : "▶"; + + public bool ShowStatusOverlay => IsLoading || ShowErrorOverlay || HasNoClipSelected; + + public bool ShowVideoHosts => !ShowStatusOverlay; + + public bool HasError => ShowErrorOverlay; + + public bool HasNoClipSelected => SelectedClip is null && !IsLoading && !ShowErrorOverlay; + + public bool IsIndeterminateProgress => IsLoading && !IsRendering; + + public bool IsGridViewSelected => SelectedCameraView == GridCameraView; + + public bool IsSingleCameraViewSelected => !IsGridViewSelected; + + public bool IsFrontViewSelected => SelectedCameraView == FrontCameraView; + + public bool IsRearViewSelected => SelectedCameraView == RearCameraView; + + public bool IsLeftViewSelected => SelectedCameraView == LeftCameraView; + + public bool IsRightViewSelected => SelectedCameraView == RightCameraView; + + public string ActiveCameraLabel => SelectedCameraView switch + { + GridCameraView => "Grid", + RearCameraView => "Rear", + LeftCameraView => "Left", + RightCameraView => "Right", + _ => "Front", + }; + + public string LoadingStatusText => IsRendering + ? $"Rendering... {RenderProgressPercent}%" + : "Loading..."; + + public int RenderProgressPercent => (int)(RenderProgress * 100); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FilteredClips))] + private string _filterText = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNoClipSelected))] + [NotifyPropertyChangedFor(nameof(ShowStatusOverlay))] + [NotifyPropertyChangedFor(nameof(ShowVideoHosts))] + [NotifyPropertyChangedFor(nameof(CanPlayPause))] + private CamClip _selectedClip; + + [ObservableProperty] + private string _errorTitle; + + [ObservableProperty] + private string _errorDetails; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowStatusOverlay))] + [NotifyPropertyChangedFor(nameof(ShowVideoHosts))] + [NotifyPropertyChangedFor(nameof(HasNoClipSelected))] + [NotifyPropertyChangedFor(nameof(HasError))] + private bool _showErrorOverlay; + + [ObservableProperty] + private bool _canDismissError = true; + + [ObservableProperty] + private bool _showFFmpegDownloadButton; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowMainContent))] + private bool _showAboutPage; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanPlayPause))] + [NotifyPropertyChangedFor(nameof(CanStop))] + [NotifyPropertyChangedFor(nameof(LoadingStatusText))] + [NotifyPropertyChangedFor(nameof(IsIndeterminateProgress))] + [NotifyPropertyChangedFor(nameof(ShowStatusOverlay))] + [NotifyPropertyChangedFor(nameof(ShowVideoHosts))] + [NotifyPropertyChangedFor(nameof(HasNoClipSelected))] + [NotifyPropertyChangedFor(nameof(CanSeek))] + private bool _isLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LoadingStatusText))] + [NotifyPropertyChangedFor(nameof(IsIndeterminateProgress))] + private bool _isRendering; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RenderProgressPercent))] + [NotifyPropertyChangedFor(nameof(LoadingStatusText))] + private double _renderProgress; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PositionText))] + private double _seekPosition; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PlayPauseIcon))] + [NotifyPropertyChangedFor(nameof(CanPlayPause))] + [NotifyPropertyChangedFor(nameof(CanStop))] + private bool _isPlaying; + + [ObservableProperty] + private double _selectedPlaybackSpeed = 1.0; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsGridViewSelected))] + [NotifyPropertyChangedFor(nameof(IsSingleCameraViewSelected))] + [NotifyPropertyChangedFor(nameof(IsFrontViewSelected))] + [NotifyPropertyChangedFor(nameof(IsRearViewSelected))] + [NotifyPropertyChangedFor(nameof(IsLeftViewSelected))] + [NotifyPropertyChangedFor(nameof(IsRightViewSelected))] + [NotifyPropertyChangedFor(nameof(ActiveCameraLabel))] + private string _selectedCameraView = FrontCameraView; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUpdateBadge))] + [NotifyPropertyChangedFor(nameof(UpdateStatusTitle))] + [NotifyPropertyChangedFor(nameof(UpdateStatusDetails))] + private bool _isUpdateAvailable; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LatestVersionText))] + [NotifyPropertyChangedFor(nameof(LatestReleaseUrl))] + [NotifyPropertyChangedFor(nameof(UpdateStatusDetails))] + private UpdateRelease _latestRelease; + + public async Task InitializeAsync() + { + if (_isInitialized) + return; + + _isInitialized = true; + Log.Debug("Initializing main window"); + +#if DEBUG + Log.Debug("Skipping update check in debug build"); +#else + _ = UpdateLatestReleaseAsync(); +#endif + + if (_flyleafRuntime.TryStart()) + { + InitializePlayer(); + LoadClips(CamStorage.FindCommonRoots()); + } + else + { + ShowFFmpegMissingError(); + } + } + + public void Shutdown() + { + var controller = _playerController; + _playerController = null; + + if (controller is null) + return; + + controller.PropertyChanged -= PlayerControllerOnPropertyChanged; + controller.Dispose(); + } + + private void InitializePlayer() + { + if (_playerController is not null) + return; + + _playerController = _playerControllerFactory(); + _playerController.PropertyChanged += PlayerControllerOnPropertyChanged; + _playerController.PlaybackSpeed = SelectedPlaybackSpeed; + } + + private void LoadClips(IEnumerable roots) + { + ClearError(); + _allClips.Clear(); + SelectedClip = null; + + var rootList = roots?.Where(root => !string.IsNullOrWhiteSpace(root)).ToList() ?? []; + if (rootList.Count == 0) + { + Log.Information("No dashcam roots found"); + ShowError( + "No Dashcam Folders Found", + "Click 'Select Folder' to choose a folder containing Tesla dashcam footage (TeslaCam folder).", + canDismiss: true); + RefreshClipState(); + return; + } + + Log.Information("Loading dashcam clips. RootCount={RootCount}; Roots={Roots}", rootList.Count, rootList); + var totalStopwatch = Stopwatch.StartNew(); + var failedRoots = 0; + + foreach (var root in rootList) + { + var rootStopwatch = Stopwatch.StartNew(); + Log.Debug("Scanning dashcam root. Root={Root}", root); + + try + { + var storage = CamStorage.Map(root); + _allClips.AddRange(storage.Clips); + Log.Information( + "Scanned dashcam root. Root={Root}; ClipCount={ClipCount}; ElapsedMs={ElapsedMs}", + root, + storage.Clips.Count, + rootStopwatch.ElapsedMilliseconds); + } + catch (UnauthorizedAccessException ex) + { + failedRoots++; + Log.Error(ex, "Access denied while loading dashcam root. Root={Root}", root); + ShowError("Access Denied", $"Cannot access folder: {root}\n\nCheck that you have permission to read this location."); + } + catch (Exception ex) + { + failedRoots++; + Log.Error(ex, "Failed to load dashcam root. Root={Root}", root); + ShowError("Error Loading Clips", $"Failed to load clips from:\n{root}\n\nError: {ex.Message}"); + } + } + + _playerController?.LoadClips(_allClips); + RefreshClipState(); + Log.Information( + "Finished loading dashcam clips. ClipCount={ClipCount}; RootCount={RootCount}; FailedRootCount={FailedRootCount}; ElapsedMs={ElapsedMs}", + _allClips.Count, + rootList.Count, + failedRoots, + totalStopwatch.ElapsedMilliseconds); + } + + private void RefreshClipState() + { + OnPropertyChanged(nameof(FilteredClips)); + OnPropertyChanged(nameof(HasNoClipSelected)); + OnPropertyChanged(nameof(ShowStatusOverlay)); + OnPropertyChanged(nameof(ShowVideoHosts)); + OnPropertyChanged(nameof(CanPlayPause)); + OnPropertyChanged(nameof(CanGoNext)); + OnPropertyChanged(nameof(CanGoPrevious)); + } + + [RelayCommand] + private async Task OpenFolderAsync() + { + Log.Debug("Opening folder picker"); + + var dialog = new OpenFolderDialog + { + Multiselect = true, + Title = "Select a folder containing Tesla dashcam footage (TeslaCam folder)", + }; + + if (dialog.ShowDialog() == true) + { + Log.Information( + "User selected dashcam folders. FolderCount={FolderCount}; Folders={Folders}", + dialog.FolderNames.Length, + dialog.FolderNames); + + if (_playerController is not null) + { + await _playerController.StopAsync(); + } + + LoadClips(dialog.FolderNames); + } + else + { + Log.Debug("Folder picker canceled"); + } + } + + [RelayCommand] + private async Task PlayPauseAsync() + { + if (_playerController is not null) + { + await _playerController.TogglePlayPauseAsync(); + } + } + + [RelayCommand] + private async Task StopAsync() + { + if (_playerController is null) + return; + + await _playerController.StopAsync(); + SeekPosition = 0; + } + + [RelayCommand] + private async Task PreviousAsync() + { + if (_playerController is null) + return; + + await _playerController.PreviousAsync(); + SelectedClip = _playerController.CurrentClip; + } + + [RelayCommand] + private async Task NextAsync() + { + if (_playerController is null) + return; + + await _playerController.NextAsync(); + SelectedClip = _playerController.CurrentClip; + } + + [RelayCommand] + private async Task DownloadFFmpegAsync() + { + IsLoading = true; + ClearError(); + + try + { + Log.Debug("Starting FFmpeg download workflow"); + await PackageManager.DownloadAndExtractFFmpeg(); + if (_flyleafRuntime.TryStart()) + { + InitializePlayer(); + LoadClips(CamStorage.FindCommonRoots()); + } + else + { + ShowFFmpegMissingError(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to download FFmpeg"); + ShowError("Download Failed", $"Failed to download FFmpeg: {ex.Message}"); + ShowFFmpegDownloadButton = true; + } + finally + { + IsLoading = false; + } + } + + private async Task PlaySelectedClipAsync() + { + if (SelectedClip is null || _playerController is null) + return; + + ClearError(); + IsLoading = true; + await Dispatcher.Yield(DispatcherPriority.Background); + + try + { + await _playerController.GoToClipAsync(SelectedClip); + } + catch (Exception ex) + { + IsLoading = false; + Log.Error( + ex, + "Failed to play selected clip. ClipName={ClipName}; ClipPath={ClipPath}", + SelectedClip.Name, + SelectedClip.FullPath); + ShowError("Playback Failed", $"Could not play clip: {SelectedClip.Name}\n\nError: {ex.Message}"); + } + } + + public async Task HandleKeyDownAsync(Key key, ModifierKeys modifiers) + { + if ((key == Key.F && modifiers == ModifierKeys.Control) || + (key == Key.F3 && modifiers == ModifierKeys.None)) + { + ShowAboutPage = false; + SearchBoxFocusRequested?.Invoke(this, EventArgs.Empty); + return true; + } + + if (_playerController is null) + { + return false; + } + + switch (key) + { + case Key.Space: + await _playerController.TogglePlayPauseAsync(); + return true; + + case Key.Left: + if (modifiers == ModifierKeys.Control && CanGoPrevious) + { + await _playerController.PreviousAsync(); + SelectedClip = _playerController.CurrentClip; + } + else if (CanSeek) + { + var position = _playerController.Position - TimeSpan.FromSeconds(5); + await _playerController.SeekAsync(position < TimeSpan.Zero ? TimeSpan.Zero : position); + } + + return true; + + case Key.Right: + if (modifiers == ModifierKeys.Control && CanGoNext) + { + await _playerController.NextAsync(); + SelectedClip = _playerController.CurrentClip; + } + else if (CanSeek) + { + var duration = _playerController.Duration; + var position = _playerController.Position + TimeSpan.FromSeconds(5); + await _playerController.SeekAsync(position > duration ? duration : position); + } + + return true; + } + + return false; + } + + public void BeginSeek() + { + if (CanSeek) + { + _isSeeking = true; + } + } + + public async Task EndSeekAsync() + { + if (_playerController is null || !CanSeek) + { + _isSeeking = false; + return; + } + + await SeekToCurrentPositionAsync(); + _isSeeking = false; + } + + public void OnSeekSliderValueChanged() + { + if (_isSeeking) + { + OnPropertyChanged(nameof(PositionText)); + } + } + + private async Task SeekToCurrentPositionAsync() + { + if (_playerController is null) + return; + + var duration = _playerController.Duration; + if (duration.TotalSeconds > 0) + { + var targetPosition = TimeSpan.FromSeconds(SeekPosition * duration.TotalSeconds); + await _playerController.SeekAsync(targetPosition); + } + } + + private void UpdateSeekPositionFromController() + { + if (_playerController is null || _isSeeking) + return; + + var duration = _playerController.Duration; + SeekPosition = duration.TotalSeconds > 0 + ? Math.Clamp(_playerController.Position.TotalSeconds / duration.TotalSeconds, 0, 1) + : 0; + } + + private void PlayerControllerOnPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrWhiteSpace(e.PropertyName)) + return; + + RunOnUiThread(() => HandlePlayerControllerPropertyChanged(e.PropertyName)); + } + + private void HandlePlayerControllerPropertyChanged(string propertyName) + { + if (_playerController is null) + return; + + switch (propertyName) + { + case nameof(VideoPlayerController.IsLoading): + IsLoading = _playerController.IsLoading; + break; + + case nameof(VideoPlayerController.IsPlaying): + IsPlaying = _playerController.IsPlaying; + break; + + case nameof(VideoPlayerController.Duration): + UpdateSeekPositionFromController(); + OnPropertyChanged(nameof(DurationText)); + OnPropertyChanged(nameof(PositionText)); + OnPropertyChanged(nameof(CanSeek)); + break; + + case nameof(VideoPlayerController.Position): + UpdateSeekPositionFromController(); + break; + + case nameof(VideoPlayerController.ErrorMessage): + if (_playerController.ErrorMessage is not null) + { + ShowError("Playback Error", _playerController.ErrorMessage); + } + + break; + + case nameof(VideoPlayerController.CurrentClip): + SelectedClip = _playerController.CurrentClip; + RefreshClipState(); + break; + + case nameof(VideoPlayerController.IsMediaOpen): + OnPropertyChanged(nameof(CanSeek)); + break; + } + } + + internal static string FormatTimeSpan(TimeSpan ts) + { + return ts.TotalHours >= 1 + ? ts.ToString(@"h\:mm\:ss") + : ts.ToString(@"m\:ss"); + } + + private static string FormatVersion(Version version) + { + if (version is null) + { + return "Unknown"; + } + + if (version.Revision >= 0) + { + return version.ToString(4); + } + + return version.Build >= 0 + ? version.ToString(3) + : version.ToString(2); + } + + private void ShowError(string title, string details, bool canDismiss = true) + { + ErrorTitle = title; + ErrorDetails = details; + CanDismissError = canDismiss; + ShowErrorOverlay = true; + } + + private void ClearError() + { + ShowErrorOverlay = false; + ShowFFmpegDownloadButton = false; + CanDismissError = true; + ErrorTitle = null; + ErrorDetails = null; + } + + [RelayCommand] + private void DismissError() + { + ClearError(); + } + + private void ShowFFmpegMissingError() + { + Log.Debug("Showing FFmpeg missing prompt"); + ShowFFmpegDownloadButton = true; + ShowError("FFmpeg Required", "FFmpeg is required to play clips. This will download about 80MB.", canDismiss: false); + } + + [RelayCommand] + private void ToggleAbout() + { + ShowAboutPage = !ShowAboutPage; + } + + [RelayCommand] + private void SelectCameraView(string cameraView) + { + SelectedCameraView = cameraView switch + { + GridCameraView => GridCameraView, + RearCameraView => RearCameraView, + LeftCameraView => LeftCameraView, + RightCameraView => RightCameraView, + _ => FrontCameraView, + }; + } + + private async Task UpdateLatestReleaseAsync() + { + var result = await _updateService.CheckForUpdateAsync(CurrentVersion); + LatestRelease = result.LatestRelease; + IsUpdateAvailable = result.IsUpdateAvailable; + + Log.Information( + "Checked for updates. CurrentVersion={CurrentVersion}; LatestVersion={LatestVersion}; IsUpdateAvailable={IsUpdateAvailable}", + FormatVersion(CurrentVersion), + LatestVersionText, + IsUpdateAvailable); + } + + private static bool CanUseClip(CamClip clip) + { + return clip is not null; + } + + [RelayCommand(CanExecute = nameof(CanUseClip))] + private void OpenClipFolder(CamClip clip) + { + if (clip is null) + { + return; + } + + if (!Directory.Exists(clip.FullPath)) + { + ShowError("Clip Folder Not Found", $"Could not find folder:\n{clip.FullPath}"); + return; + } + + Process.Start(new ProcessStartInfo(clip.FullPath) + { + UseShellExecute = true, + }); + } + + [RelayCommand(CanExecute = nameof(CanUseClip))] + private void CopyClipPath(CamClip clip) + { + if (clip is not null) + { + Clipboard.SetText(clip.FullPath); + } + } + + [RelayCommand(CanExecute = nameof(CanUseClip))] + private void CopyClipName(CamClip clip) + { + if (clip is not null) + { + Clipboard.SetText(clip.Name); + } + } + + private void RunOnUiThread(Action action) + { + if (_dispatcher.CheckAccess()) + { + action(); + return; + } + + _dispatcher.Invoke(action); + } + + partial void OnSelectedClipChanged(CamClip value) + { + if (value is not null) + { + Log.Debug( + "Selected clip changed. ClipName={ClipName}; ClipPath={ClipPath}", + value.Name, + value.FullPath); + _ = PlaySelectedClipAsync(); + } + } + + partial void OnSelectedPlaybackSpeedChanged(double value) + { + if (_playerController is not null) + { + Log.Information("Playback speed changed. Speed={PlaybackSpeed}", value); + _playerController.PlaybackSpeed = value; + } + } +}