"Refactor TimerControl: Improve countdown animation, simplify focus logic, and add shutter flash effect"

This commit is contained in:
iTob 2026-03-09 23:07:57 +01:00
parent b3c91da331
commit a3c9f9b719
5 changed files with 219 additions and 226 deletions

View File

@ -1,42 +1,70 @@
<UserControl x:Class="CamBooth.App.Features.LiveView.TimerControlRectangleAnimation"
<UserControl x:Class="CamBooth.App.Features.LiveView.TimerControlRectangleAnimation"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:CamBooth.App.Features.LiveView"
mc:Ignorable="d"
d:DesignHeight="100" d:DesignWidth="1350">
d:DesignHeight="900" d:DesignWidth="1600">
<Grid>
<!-- Hintergrund für den Timer -->
<Border CornerRadius="10" Background="Black" Padding="0" Opacity="0" x:Name="TimerContainer">
<Border x:Name="TimerContainer" Opacity="0">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- Fortschrittsanzeige -->
<Grid Height="75" Width="1350" Background="Gray" Margin="0,0,0,0">
<Rectangle x:Name="ProgressBar"
Fill="#4CAF50"
Height="75"
HorizontalAlignment="Left"/>
<TextBlock x:Name="InstructionText"
VerticalAlignment="Center"
<!-- Circular countdown ring -->
<Grid Width="360" Height="360">
<!-- Track ring -->
<Ellipse Stroke="#1AFFFFFF"
StrokeThickness="12"
Width="320" Height="320"
HorizontalAlignment="Center"
FontSize="72"
FontWeight="Bold"
Foreground="White">Lächeln!</TextBlock>
VerticalAlignment="Center"/>
<!-- Progress ring: StrokeDashArray[0] is animated from full to 0 -->
<Ellipse x:Name="CountdownRing"
Stroke="#D4AF37"
StrokeThickness="12"
Width="320" Height="320"
StrokeDashArray="83.78 83.78"
StrokeDashOffset="0"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
RenderTransformOrigin="0.5,0.5"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Ellipse.RenderTransform>
<!-- Rotate so the ring starts at 12 o'clock -->
<RotateTransform Angle="-90"/>
</Ellipse.RenderTransform>
</Ellipse>
<!-- Countdown number -->
<TextBlock x:Name="CountdownNumber"
FontSize="180"
FontWeight="Black"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RenderTransformOrigin="0.5,0.5"
Text="5">
<TextBlock.RenderTransform>
<ScaleTransform x:Name="CountdownNumberScale" ScaleX="1" ScaleY="1"/>
</TextBlock.RenderTransform>
</TextBlock>
</Grid>
<!-- ~1~ Countdown-Anzeige @1@ -->
<!-- <TextBlock x:Name="TimerText" -->
<!-- FontSize="32" -->
<!-- Foreground="White" -->
<!-- HorizontalAlignment="Center" -->
<!-- Text="00:00" -->
<!-- Margin="0,10"/> -->
<!-- ~1~ Status-Text @1@ -->
<!-- <TextBlock x:Name="StatusText" -->
<!-- FontSize="14" -->
<!-- Foreground="Gray" -->
<!-- HorizontalAlignment="Center" -->
<!-- Text="Timer läuft..."/> -->
<!-- Instruction text below the ring -->
<TextBlock x:Name="InstructionText"
FontSize="46"
FontWeight="Bold"
Foreground="#D4AF37"
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"
MaxWidth="900"
Margin="0,24,0,0"
Text="Lächeln! 😊"/>
</StackPanel>
</Border>
</Grid>

View File

@ -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<string> _photoInstructions = new List<string>
{
"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;
}
private void OnTimerTick(object sender, EventArgs e)
{
if (_remainingTime > 0)
{
_remainingTime--;
// TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss");
}
else
{
_timer.Stop();
// StatusText.Text = "Zeit abgelaufen!";
StopProgressBarAnimation();
OnTimerEllapsed?.Invoke();
}
}
public void StartTimer(int durationInSeconds)
{
_totalDuration = durationInSeconds;
_remainingTime = durationInSeconds;
// 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();
}
private void StopProgressBarAnimation()
{
_progressBarAnimation?.Stop();
}
private void ShowRandomInstruction()
{
if (_photoInstructions.Count > 0)
{
int randomIndex = _random.Next(_photoInstructions.Count);
InstructionText.Text = _photoInstructions[randomIndex];
}
}
public TimerControlRectangleAnimation()
{
InitializeComponent();
_ticker.Tick += OnTick;
}
public void StartTimer(int durationInSeconds)
{
_totalDuration = durationInSeconds;
_remainingTime = durationInSeconds;
CountdownNumber.Text = _remainingTime.ToString();
InstructionText.Text = Instructions[_random.Next(Instructions.Length)];
// Reset ring offset so ring appears full
CountdownRing.StrokeDashOffset = 0;
CountdownRing.StrokeDashArray = new DoubleCollection { RingFullDashUnits, RingFullDashUnits };
FadeIn();
StartRingAnimation();
_ticker.Start();
}
private void OnTick(object? sender, EventArgs e)
{
_remainingTime--;
if (_remainingTime > 0)
{
CountdownNumber.Text = _remainingTime.ToString();
AnimateNumberPop();
}
else
{
_ticker.Stop();
CountdownNumber.Text = "0";
AnimateNumberPop();
StopRingAnimation();
OnTimerEllapsed?.Invoke();
}
}
private void FadeIn()
{
TimerContainer.BeginAnimation(OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
}
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 });
}
}

View File

@ -40,19 +40,16 @@
Background="Transparent"
Panel.ZIndex="1" />
<!-- Inhalt der dritten Zeile -->
<StackPanel Grid.Row="0" Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Bottom" Visibility="Hidden" Name="TimerPanel" Background="#AA000000" Panel.ZIndex="2"
Margin="0 0 0 0">
<TextBlock x:Name="CaptureStatusText"
Text="Scharfstellen..."
Visibility="Collapsed"
Foreground="White"
FontSize="36"
FontWeight="Bold"
<!-- Timer overlay: fullscreen so the ring centers on the camera view -->
<Grid Grid.Row="0"
x:Name="TimerPanel"
Background="Transparent"
Panel.ZIndex="2"
Visibility="Hidden">
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation"
HorizontalAlignment="Center"
Margin="24 24 24 12"/>
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
VerticalAlignment="Center"/>
</Grid>
<!-- Action Buttons Container (bottom-right) -->
<StackPanel Grid.Row="0"
@ -387,6 +384,14 @@
</Border>
</Grid>
<!-- Flash overlay: briefly flashes white on shutter release -->
<Grid x:Name="FlashOverlay"
Grid.RowSpan="2"
Background="White"
Opacity="0"
Panel.ZIndex="9"
IsHitTestVisible="False"/>
<!-- Loading Overlay -->
<Grid Grid.RowSpan="2"
x:Name="LoadingOverlay"

View File

@ -1,11 +1,9 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
using CamBooth.App.Features.Camera;
@ -39,9 +37,6 @@ public partial class MainWindow : Window
private const string ShutdownGlyphOpen = "\uE711";
private const double ShutdownSliderOffset = 160;
private readonly DispatcherTimer _focusStatusAnimationTimer = new() { Interval = TimeSpan.FromMilliseconds(250) };
private int _focusStatusDots;
public MainWindow(
Logger logger,
@ -60,7 +55,7 @@ public partial class MainWindow : Window
InitializeComponent();
// Wire ViewModel events to UI
// Wire ViewModel events to UI
_viewModel.LoadingProgressChanged += (status, count) =>
{
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();
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()
/// <summary>Briefly flashes the screen white to simulate a camera shutter.</summary>
private void TriggerShutterFlash()
{
_focusStatusDots = 0;
CaptureStatusText.Text = "Scharfstellen";
_focusStatusAnimationTimer.Start();
}
private void StopFocusStatusAnimation()
FlashOverlay.BeginAnimation(OpacityProperty,
new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(450))
{
_focusStatusAnimationTimer.Stop();
_focusStatusDots = 0;
EasingFunction = new PowerEase { Power = 2, EasingMode = EasingMode.EaseIn }
});
}
private async Task ShowLatestPhotoDialogAsync()

View File

@ -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<string, string>? LoadingProgressChanged; // (statusText, countText)
public event Action? InitializationCompleted;
@ -68,10 +71,30 @@ public class MainWindowViewModel
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Called when the timer fires. Focus is already guaranteed complete — takes photo immediately.
/// Throws on camera error so the caller can show a message.
/// </summary>
public void TakePhotoAfterTimer()
{
IsPhotoProcessRunning = false;
_cameraService.TakePhoto();
@ -82,6 +105,7 @@ public class MainWindowViewModel
public void StartPhotoProcess()
{
IsPhotoProcessRunning = true;
_focusTask = Task.CompletedTask;
DismissGalleryPrompt();
}