diff --git a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml b/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml index 83d12cf..a56e05f 100644 --- a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml +++ b/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml @@ -1,42 +1,70 @@ - + d:DesignHeight="900" d:DesignWidth="1600"> - - + - - - - Lächeln! + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + diff --git a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml.cs b/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml.cs index 76857a2..44af9a5 100644 --- a/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml.cs +++ b/src/CamBooth/CamBooth.App/Features/LiveView/TimerControlRectangleAnimation.xaml.cs @@ -1,144 +1,113 @@ -using System.Windows; +using System.Windows; using System.Windows.Controls; +using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Shapes; using System.Windows.Threading; -using Microsoft.Win32; - namespace CamBooth.App.Features.LiveView; public partial class TimerControlRectangleAnimation : UserControl { - public delegate void TimerElapsedEventHandler(); +// Ellipse diameter=320, StrokeThickness=12 → circumference = π*320/12 ≈ 83.78 dash-units +private static readonly double RingFullDashUnits = Math.PI * 320.0 / 12.0; - public static event TimerElapsedEventHandler OnTimerEllapsed; +public static event Action? OnTimerEllapsed; - private DispatcherTimer _timer; +private readonly DispatcherTimer _ticker = new() { Interval = TimeSpan.FromSeconds(1) }; +private readonly Random _random = new(); - private int _remainingTime; // Zeit in Sekunden +private int _remainingTime; +private double _totalDuration; - private double _totalDuration; // Gesamtzeit in Sekunden - - private Storyboard _progressBarAnimation; - - private Random _random = new Random(); - - private List _photoInstructions = new List - { - "Lächeln! 😊", - "Hasenohren machen! 🐰", - "Zunge rausstrecken! 👅", - "Grimasse ziehen! 😝", - "Daumen hoch! 👍", - "Peace-Zeichen! ✌️", - "Lustig gucken! 🤪", - "Crazy Face! 🤯", - "Küsschen! 😘", - "Winken! 👋", - "Herz mit den Händen! ❤️", - "Verrückt sein! 🤪", - "Überrascht schauen! 😲", - "Cool bleiben! 😎", - "Lachen! 😄", - "Zähne zeigen! 😁", - "Schnute ziehen! 😗", - "Augen zukneifen! 😆", - "Arm hochstrecken! 🙌", - "Gruppe umarmen! 🤗" - }; +private static readonly string[] Instructions = +[ +"Lächeln! 😊", "Hasenohren machen! 🐰", "Zunge rausstrecken! 👅", +"Grimasse ziehen! 😝", "Daumen hoch! 👍", "Peace-Zeichen! ✌️", +"Lustig gucken! 🤪", "Crazy Face! 🤯", "Küsschen! 😘", +"Winken! 👋", "Herz mit den Händen! ❤️", "Überrascht schauen! 😲", +"Cool bleiben! 😎", "Lachen! 😄", "Zähne zeigen! 😁", +"Schnute ziehen! 😗", "Arm hochstrecken! 🙌", "Gruppe umarmen! 🤗" +]; - public TimerControlRectangleAnimation() - { - InitializeComponent(); - InitializeTimer(); - } - - private void InitializeTimer() - { - _timer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(1) - }; - _timer.Tick += OnTimerTick; - } +public TimerControlRectangleAnimation() +{ +InitializeComponent(); +_ticker.Tick += OnTick; +} - private void OnTimerTick(object sender, EventArgs e) - { - if (_remainingTime > 0) - { - _remainingTime--; +public void StartTimer(int durationInSeconds) +{ +_totalDuration = durationInSeconds; +_remainingTime = durationInSeconds; - // TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss"); - } - else - { - _timer.Stop(); +CountdownNumber.Text = _remainingTime.ToString(); +InstructionText.Text = Instructions[_random.Next(Instructions.Length)]; - // StatusText.Text = "Zeit abgelaufen!"; - StopProgressBarAnimation(); - - OnTimerEllapsed?.Invoke(); - } - } +// Reset ring offset so ring appears full +CountdownRing.StrokeDashOffset = 0; +CountdownRing.StrokeDashArray = new DoubleCollection { RingFullDashUnits, RingFullDashUnits }; + +FadeIn(); +StartRingAnimation(); +_ticker.Start(); +} - public void StartTimer(int durationInSeconds) - { - _totalDuration = durationInSeconds; - _remainingTime = durationInSeconds; +private void OnTick(object? sender, EventArgs e) +{ +_remainingTime--; - // TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss"); - // StatusText.Text = "Timer läuft..."; - - // Show initial random instruction - ShowRandomInstruction(); - - _timer.Start(); - - StartProgressBarAnimation(); - ShowTimer(); - } - - private void ShowTimer() - { - var fadeInAnimation = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)); - TimerContainer.BeginAnimation(OpacityProperty, fadeInAnimation); - } - - private void StartProgressBarAnimation() - { - // Fortschrittsbalken-Animation - _progressBarAnimation = new Storyboard(); - var widthAnimation = new DoubleAnimation - { - From = 1350, // Volle Breite des Containers - To = 0, // Endet bei 0 Breite - Duration = TimeSpan.FromSeconds(_totalDuration), - FillBehavior = FillBehavior.Stop - }; - - Storyboard.SetTarget(widthAnimation, ProgressBar); - Storyboard.SetTargetProperty(widthAnimation, new PropertyPath(Rectangle.WidthProperty)); - - _progressBarAnimation.Children.Add(widthAnimation); - _progressBarAnimation.Begin(); - } +if (_remainingTime > 0) +{ +CountdownNumber.Text = _remainingTime.ToString(); +AnimateNumberPop(); +} +else +{ +_ticker.Stop(); +CountdownNumber.Text = "0"; +AnimateNumberPop(); +StopRingAnimation(); +OnTimerEllapsed?.Invoke(); +} +} - private void StopProgressBarAnimation() - { - _progressBarAnimation?.Stop(); - } +private void FadeIn() +{ +TimerContainer.BeginAnimation(OpacityProperty, +new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))); +} - private void ShowRandomInstruction() - { - if (_photoInstructions.Count > 0) - { - int randomIndex = _random.Next(_photoInstructions.Count); - InstructionText.Text = _photoInstructions[randomIndex]; - } - } -} \ No newline at end of file + +private void StartRingAnimation() +{ + // StrokeDashOffset IS a DependencyProperty — animate directly, no Storyboard needed. + // Pattern: dash=C, gap=C. Offset 0 → full ring; offset C → empty ring. + CountdownRing.BeginAnimation(Shape.StrokeDashOffsetProperty, + new DoubleAnimation(0, RingFullDashUnits, + new Duration(TimeSpan.FromSeconds(_totalDuration))) + { + FillBehavior = FillBehavior.HoldEnd + }); +} + + +private void StopRingAnimation() => + CountdownRing.BeginAnimation(Shape.StrokeDashOffsetProperty, null); + + +private void AnimateNumberPop() +{ +var easing = new BackEase { Amplitude = 0.4, EasingMode = EasingMode.EaseOut }; +CountdownNumberScale.ScaleX = 1.4; +CountdownNumberScale.ScaleY = 1.4; +CountdownNumberScale.BeginAnimation(ScaleTransform.ScaleXProperty, +new DoubleAnimation(1.4, 1.0, TimeSpan.FromMilliseconds(450)) { EasingFunction = easing }); +CountdownNumberScale.BeginAnimation(ScaleTransform.ScaleYProperty, +new DoubleAnimation(1.4, 1.0, TimeSpan.FromMilliseconds(450)) { EasingFunction = easing }); +} +} diff --git a/src/CamBooth/CamBooth.App/MainWindow.xaml b/src/CamBooth/CamBooth.App/MainWindow.xaml index 7686fb9..319e379 100644 --- a/src/CamBooth/CamBooth.App/MainWindow.xaml +++ b/src/CamBooth/CamBooth.App/MainWindow.xaml @@ -40,19 +40,16 @@ Background="Transparent" Panel.ZIndex="1" /> - - - - - + + + + + + + { LoadingStatusText.Text = status; @@ -74,18 +69,11 @@ public partial class MainWindow : Window _viewModel.GalleryPromptRequested += () => GalleryPrompt.Visibility = Visibility.Visible; _viewModel.GalleryPromptDismissed += () => GalleryPrompt.Visibility = Visibility.Collapsed; -// Wire service events + // Wire service events _pictureGalleryService.NewPhotoCountChanged += OnNewPhotoCountChanged; TimerControlRectangleAnimation.OnTimerEllapsed += OnTimerElapsed; -// Focus animation timer - _focusStatusAnimationTimer.Tick += (_, _) => - { - _focusStatusDots = (_focusStatusDots + 1) % 4; - CaptureStatusText.Text = $"Scharfstellen{new string('.', _focusStatusDots)}"; - }; - -// Initial UI state + // Initial UI state _isDebugConsoleVisible = _appSettings.IsDebugConsoleVisible; SetVisibilityDebugConsole(_isDebugConsoleVisible); SetVisibilityPicturePanel(false); @@ -100,25 +88,22 @@ public partial class MainWindow : Window } - #region Event handlers (wired in XAML or in ctor) + #region Event handlers private void OnTimerElapsed() { + SwitchButtonAndTimerPanel(); try { - _viewModel.OnTimerElapsed(); + // Focus is guaranteed complete before the timer fires (bounded timeout). + _viewModel.TakePhotoAfterTimer(); + TriggerShutterFlash(); } catch (Exception ex) { _logger.Error(ex.Message); System.Windows.MessageBox.Show("Sorry, da ging was schief! Bitte nochmal probieren."); } - finally - { - StopFocusStatusAnimation(); - CaptureStatusText.Visibility = Visibility.Collapsed; - SwitchButtonAndTimerPanel(); - } } private void OnNewPhotoCountChanged(object? sender, int count) @@ -142,7 +127,7 @@ public partial class MainWindow : Window _liveViewPage?.Dispose(); } -// XAML-bound handlers + // XAML-bound handlers private void StartExperience(object sender, RoutedEventArgs e) { StartLiveViewIfNeeded(); @@ -152,36 +137,21 @@ public partial class MainWindow : Window ShutdownDock.Visibility = Visibility.Visible; } - private async void StartTakePhotoProcess(object sender, RoutedEventArgs e) + private void StartTakePhotoProcess(object sender, RoutedEventArgs e) { if (_viewModel.IsPhotoProcessRunning) return; ClosePicturePanel(); _viewModel.StartPhotoProcess(); + SwitchButtonAndTimerPanel(); + TimerControlRectangleAnimation.StartTimer(_appSettings.PhotoCountdownSeconds); - try - { - SwitchButtonAndTimerPanel(); - TimerControlRectangleAnimation.StartTimer(_appSettings.PhotoCountdownSeconds); - StartFocusStatusAnimation(); - CaptureStatusText.Visibility = Visibility.Visible; - - await Task.Delay(TimeSpan.FromSeconds(_appSettings.FocusDelaySeconds)); - if (!_viewModel.IsPhotoProcessRunning) return; - - await _cameraService.PrepareFocusAsync(_appSettings.FocusTimeoutMs); - StopFocusStatusAnimation(); - CaptureStatusText.Visibility = Visibility.Collapsed; - } - catch (Exception ex) - { - _viewModel.CancelPhotoProcess(); - StopFocusStatusAnimation(); - CaptureStatusText.Visibility = Visibility.Collapsed; - if (TimerPanel.Visibility == Visibility.Visible) - SwitchButtonAndTimerPanel(); - _logger.Error(ex.Message); - } + // Autofocus runs silently in the background during the countdown. + // The effective timeout is capped so focus always completes before the timer fires. + _ = _viewModel.BeginFocusAsync( + _appSettings.FocusDelaySeconds, + _appSettings.FocusTimeoutMs, + _appSettings.PhotoCountdownSeconds); } private void SetVisibilityPicturePanel(object sender, MouseButtonEventArgs e) => @@ -203,7 +173,7 @@ public partial class MainWindow : Window Duration = TimeSpan.FromMilliseconds(250), EasingFunction = new QuadraticEase() }; - ShutdownSliderTransform.BeginAnimation(System.Windows.Media.TranslateTransform.XProperty, animation); + ShutdownSliderTransform.BeginAnimation(TranslateTransform.XProperty, animation); } private async void ShutdownWindows(object sender, RoutedEventArgs e) @@ -360,17 +330,14 @@ public partial class MainWindow : Window TimerPanel.Visibility = TimerPanel.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden; } - private void StartFocusStatusAnimation() + /// Briefly flashes the screen white to simulate a camera shutter. + private void TriggerShutterFlash() { - _focusStatusDots = 0; - CaptureStatusText.Text = "Scharfstellen"; - _focusStatusAnimationTimer.Start(); - } - - private void StopFocusStatusAnimation() - { - _focusStatusAnimationTimer.Stop(); - _focusStatusDots = 0; + FlashOverlay.BeginAnimation(OpacityProperty, + new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(450)) + { + EasingFunction = new PowerEase { Power = 2, EasingMode = EasingMode.EaseIn } + }); } private async Task ShowLatestPhotoDialogAsync() @@ -403,4 +370,4 @@ public partial class MainWindow : Window } #endregion -} \ No newline at end of file +} diff --git a/src/CamBooth/CamBooth.App/MainWindowViewModel.cs b/src/CamBooth/CamBooth.App/MainWindowViewModel.cs index 76c3070..e4d7202 100644 --- a/src/CamBooth/CamBooth.App/MainWindowViewModel.cs +++ b/src/CamBooth/CamBooth.App/MainWindowViewModel.cs @@ -21,6 +21,9 @@ public class MainWindowViewModel public bool IsPhotoProcessRunning { get; private set; } + // The running focus task — shared between StartTakePhotoProcess and OnTimerElapsed + private Task _focusTask = Task.CompletedTask; + // Events → MainWindow subscribes and updates UI public event Action? LoadingProgressChanged; // (statusText, countText) public event Action? InitializationCompleted; @@ -68,10 +71,30 @@ public class MainWindowViewModel /// - /// Called by MainWindow when the countdown timer fires and a photo should be taken. - /// Throws on camera error so MainWindow can show a message box. + /// Starts autofocus in the background. The effective timeout is capped so focus + /// is guaranteed to complete before the countdown timer fires. /// - public void OnTimerElapsed() + public Task BeginFocusAsync(int delaySeconds, int focusTimeoutMs, int countdownSeconds) + { + // Ensure focus always finishes at least 500ms before the timer fires. + var maxAllowedMs = (countdownSeconds - delaySeconds) * 1000 - 500; + var effectiveTimeoutMs = Math.Max(200, Math.Min(focusTimeoutMs, maxAllowedMs)); + _focusTask = RunFocusAsync(delaySeconds, effectiveTimeoutMs); + return _focusTask; + } + + private async Task RunFocusAsync(int delaySeconds, int focusTimeoutMs) + { + if (delaySeconds > 0) + await Task.Delay(TimeSpan.FromSeconds(delaySeconds)); + await _cameraService.PrepareFocusAsync(focusTimeoutMs); + } + + /// + /// Called when the timer fires. Focus is already guaranteed complete — takes photo immediately. + /// Throws on camera error so the caller can show a message. + /// + public void TakePhotoAfterTimer() { IsPhotoProcessRunning = false; _cameraService.TakePhoto(); @@ -82,6 +105,7 @@ public class MainWindowViewModel public void StartPhotoProcess() { IsPhotoProcessRunning = true; + _focusTask = Task.CompletedTask; DismissGalleryPrompt(); }