"Refactor LiveView and add new Capture features: Remove TimerControl and introduce Capture components (PhotoCompositor, CaptureMode, PhotoCaptureCoordinator, GifEncoder) with enhanced functionality, including photo strip compositing, GIF creation, and burst capture handling."

This commit is contained in:
iTob 2026-07-03 15:00:39 +02:00
parent 9ad55bd90a
commit 0e4ecb3331
13 changed files with 992 additions and 212 deletions

View File

@ -2,6 +2,7 @@ using System.Windows;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
using CamBooth.App.Features.Camera;
using CamBooth.App.Features.Capture;
using CamBooth.App.Features.PhotoPrismUpload;
using CamBooth.App.Features.PictureGallery;
using EDSDKLib.API.Base;
@ -89,6 +90,7 @@ public partial class App : Application
services.AddSingleton<AppSettingsService>();
services.AddSingleton<PictureGalleryService>();
services.AddSingleton<CameraService>();
services.AddSingleton<PhotoCaptureCoordinator>();
services.AddSingleton<PhotoPrismAuthService>();
services.AddSingleton<PhotoPrismUploadService>();
services.AddSingleton<PhotoPrismUploadQueueService>();

View File

@ -44,7 +44,7 @@ public class AppSettingsService
public string? AppName => configuration["AppSettings:AppName"];
public bool IsDebugConsoleVisible => bool.Parse(configuration["AppSettings:DebugConsoleVisible"] ?? string.Empty);
public bool IsDebugConsoleVisible => bool.Parse(configuration["AppSettings:DebugConsoleVisible"] ?? "false");
public string? PictureLocation => configuration["AppSettings:PictureLocation"];
@ -58,6 +58,50 @@ public class AppSettingsService
public bool UseMockCamera => bool.Parse(configuration["AppSettings:UseMockCamera"] ?? "false");
// Sekunden ohne Interaktion, bis der Idle-/Attract-Screen (Slideshow) erscheint. 0 = deaktiviert.
public int IdleTimeoutSeconds => int.Parse(configuration["AppSettings:IdleTimeoutSeconds"] ?? "60");
// Zeigt nach der Aufnahme das Foto mit "Behalten / Nochmal".
public bool IsReviewEnabled => bool.Parse(configuration["AppSettings:ReviewEnabled"] ?? "true");
// Sekunden, nach denen die Review automatisch mit "Behalten" bestätigt wird.
public int ReviewTimeoutSeconds => int.Parse(configuration["AppSettings:ReviewTimeoutSeconds"] ?? "6");
// Aufnahmemodus: "Single" (ein Foto), "Strip" (Fotostreifen), "Gif" (animiertes GIF).
public string CaptureMode => configuration["AppSettings:CaptureMode"] ?? "Single";
// Anzahl der Aufnahmen für Strip/Gif.
public int BurstCount => int.Parse(configuration["AppSettings:BurstCount"] ?? "4");
// Pause zwischen den Einzelaufnahmen eines Bursts (ms).
public int BurstIntervalMs => int.Parse(configuration["AppSettings:BurstIntervalMs"] ?? "900");
// Text in der Fußzeile des Fotostreifens. Leer = keine Fußzeile.
// Platzhalter {date} wird durch das heutige Datum ersetzt.
public string StripFooterText => configuration["AppSettings:StripFooterText"] ?? "CamBooth";
// Optionaler Logo-Pfad. Wenn gesetzt und lesbar, ersetzt das Logo den Fußzeilen-Text.
public string? StripLogoPath => configuration["AppSettings:StripLogoPath"];
// Textfarbe der Fußzeile (WPF-Farbstring, z. B. "#D4AF37").
public string StripFooterColor => configuration["AppSettings:StripFooterColor"] ?? "#D4AF37";
// Hintergrundfarbe des Streifens (z. B. "#FFFFFF").
public string StripBackgroundColor => configuration["AppSettings:StripBackgroundColor"] ?? "#FFFFFF";
// Schriftart der Fußzeile.
public string StripFooterFont => configuration["AppSettings:StripFooterFont"] ?? "Segoe UI";
// Schriftgröße der Fußzeile.
public double StripFooterFontSize =>
double.Parse(configuration["AppSettings:StripFooterFontSize"] ?? "34", System.Globalization.CultureInfo.InvariantCulture);
// Höhe der Fußzeile in Pixeln (bei Logo ggf. erhöhen).
public int StripFooterHeight => int.Parse(configuration["AppSettings:StripFooterHeight"] ?? "90");
// Konfetti-Animation nach erfolgreicher Aufnahme.
public bool IsConfettiEnabled => bool.Parse(configuration["AppSettings:ConfettiEnabled"] ?? "true");
public string? ConnectionString => configuration.GetConnectionString("DefaultConnection");
public string ConfigFileName => loadedConfigFile;
@ -71,17 +115,6 @@ public class AppSettingsService
public string? RemoteServerApiKey => configuration["LoggingSettings:RemoteServerApiKey"];
// Lychee Upload Settings (deprecated - wird durch PhotoPrism ersetzt)
public string? LycheeApiUrl => configuration["LycheeSettings:ApiUrl"];
public string? LycheeUsername => configuration["LycheeSettings:Username"];
public string? LycheePassword => configuration["LycheeSettings:Password"];
public string? LycheeDefaultAlbumId => configuration["LycheeSettings:DefaultAlbumId"];
public bool LycheeAutoUploadEnabled => bool.Parse(configuration["LycheeSettings:AutoUploadEnabled"] ?? "false");
// PhotoPrism Upload Settings
public string? PhotoPrismApiUrl => configuration["PhotoPrismSettings:ApiUrl"];

View File

@ -9,7 +9,21 @@
"FocusDelaySeconds": 2,
"FocusTimeoutMs": 3000,
"IsShutdownEnabled": false,
"UseMockCamera": false
"UseMockCamera": false,
"IdleTimeoutSeconds": 60,
"ReviewEnabled": true,
"ReviewTimeoutSeconds": 6,
"CaptureMode": "Single",
"BurstCount": 4,
"BurstIntervalMs": 900,
"StripFooterText": "CamBooth · {date}",
"StripLogoPath": "",
"StripFooterColor": "#D4AF37",
"StripBackgroundColor": "#FFFFFF",
"StripFooterFont": "Segoe UI",
"StripFooterFontSize": 34,
"StripFooterHeight": 90,
"ConfettiEnabled": true
},
"Serilog": {
"MinimumLevel": {

View File

@ -4,8 +4,6 @@ using System.Threading.Tasks;
using System.Windows;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
using CamBooth.App.Features.PhotoPrismUpload;
using CamBooth.App.Features.PictureGallery;
using EOSDigital.API;
using EOSDigital.SDK;
@ -15,8 +13,6 @@ public class CameraService : IDisposable
{
private readonly AppSettingsService _appSettings;
private readonly Logger _logger;
private readonly PictureGalleryService _pictureGalleryService;
private readonly PhotoPrismUploadQueueService _photoPrismUploadQueueService;
private readonly ICanonAPI _canonApi;
private ICamera? _mainCamera;
@ -27,20 +23,23 @@ public class CameraService : IDisposable
/// <summary>Fires whenever the camera delivers a new live-view frame.</summary>
public event Action<Stream>? LiveViewUpdated;
/// <summary>
/// Fires (on the UI dispatcher) after a captured photo has been downloaded and saved to disk.
/// The argument is the full path of the saved file. Subscribers decide what to do with it
/// (review, gallery refresh, upload) — the service no longer auto-queues uploads.
/// </summary>
public event Action<string>? PhotoSaved;
public bool IsConnected => _isConnected && _mainCamera?.SessionOpen == true;
public CameraService(
Logger logger,
AppSettingsService appSettings,
PictureGalleryService pictureGalleryService,
PhotoPrismUploadQueueService photoPrismUploadQueueService,
ICanonAPI canonApi)
{
_logger = logger;
_appSettings = appSettings;
_pictureGalleryService = pictureGalleryService;
_photoPrismUploadQueueService = photoPrismUploadQueueService;
_canonApi = canonApi;
}
@ -292,13 +291,9 @@ public class CameraService : IDisposable
var savedPath = Path.Combine(_appSettings.PictureLocation!, info.FileName);
_logger.Debug($"Download complete: {savedPath}");
Application.Current.Dispatcher.Invoke(() =>
{
_pictureGalleryService.IncrementNewPhotoCount();
_pictureGalleryService.LoadThumbnailsToCache();
});
_photoPrismUploadQueueService.QueueNewPhoto(savedPath);
// Notify subscribers on the UI thread. Gallery refresh and upload queueing are
// now the subscriber's responsibility (so e.g. a rejected photo isn't uploaded).
Application.Current.Dispatcher.Invoke(() => PhotoSaved?.Invoke(savedPath));
}
catch (Exception ex)
{

View File

@ -0,0 +1,21 @@
namespace CamBooth.App.Features.Capture;
/// <summary>How a photo session captures: a single shot, a photo strip, or an animated GIF.</summary>
public enum CaptureMode
{
Single,
Strip,
Gif
}
/// <summary>Result of a capture session — what to show/keep plus the raw frames for cleanup.</summary>
public sealed class CaptureResult
{
/// <summary>The file to present and (on keep) add to the gallery — a single photo, a strip, or a GIF.</summary>
public required string ResultPath { get; init; }
/// <summary>The raw individual frames captured. For Single this equals [ResultPath].</summary>
public required IReadOnlyList<string> FramePaths { get; init; }
public CaptureMode Mode { get; init; }
}

View File

@ -0,0 +1,180 @@
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace CamBooth.App.Features.Capture;
/// <summary>
/// Creates an animated GIF from several frames using WPF's <see cref="GifBitmapEncoder"/>.
/// WPF writes the frames but not the per-frame delay or the loop flag, so the encoded bytes are
/// post-processed to inject a NETSCAPE2.0 looping block and a Graphic Control Extension per frame.
/// </summary>
public static class GifEncoder
{
private const int TargetWidth = 480;
/// <summary>
/// Builds an animated, looping GIF from <paramref name="framePaths"/> and writes it to
/// <paramref name="outputPath"/>. <paramref name="frameDelayMs"/> is the per-frame display time.
/// </summary>
public static string CreateGif(IReadOnlyList<string> framePaths, string outputPath, int frameDelayMs = 600)
{
if (framePaths.Count == 0)
throw new ArgumentException("No frames for GIF.", nameof(framePaths));
var (w, h) = TargetSize(framePaths[0]);
var encoder = new GifBitmapEncoder();
foreach (var path in framePaths)
encoder.Frames.Add(BitmapFrame.Create(ToUniformFrame(path, w, h)));
byte[] raw;
using (var ms = new MemoryStream())
{
encoder.Save(ms);
raw = ms.ToArray();
}
var delayCs = (byte)Math.Clamp(frameDelayMs / 10, 2, 255);
var bytes = TryAddAnimation(raw, delayCs);
File.WriteAllBytes(outputPath, bytes);
return outputPath;
}
/// <summary>
/// Rewrites a static multi-frame GIF into a looping animation. On any parsing surprise it falls
/// back to the original bytes (still a valid GIF), so a capture is never lost.
/// </summary>
private static byte[] TryAddAnimation(byte[] src, byte delayCs)
{
try
{
var outBytes = new List<byte>(src.Length + 64);
int pos = 0;
// Header (6) + Logical Screen Descriptor (7)
outBytes.AddRange(src[..13]);
pos = 13;
// Global Color Table (if present)
var lsdPacked = src[10];
if ((lsdPacked & 0x80) != 0)
{
int gctBytes = 3 * (1 << ((lsdPacked & 0x07) + 1));
outBytes.AddRange(src[pos..(pos + gctBytes)]);
pos += gctBytes;
}
// Insert the loop-forever application extension once.
outBytes.AddRange(NetscapeLoopBlock());
while (pos < src.Length)
{
byte block = src[pos];
if (block == 0x3B) // trailer
{
outBytes.Add(block);
pos++;
break;
}
if (block == 0x21) // extension
{
outBytes.Add(src[pos++]); // introducer
outBytes.Add(src[pos++]); // label
pos = CopySubBlocks(src, pos, outBytes);
}
else if (block == 0x2C) // image descriptor
{
outBytes.AddRange(GraphicControlExtension(delayCs)); // our delay, before the image
outBytes.AddRange(src[pos..(pos + 10)]); // descriptor (separator + 9)
byte imgPacked = src[pos + 9];
pos += 10;
if ((imgPacked & 0x80) != 0) // local color table
{
int lctBytes = 3 * (1 << ((imgPacked & 0x07) + 1));
outBytes.AddRange(src[pos..(pos + lctBytes)]);
pos += lctBytes;
}
outBytes.Add(src[pos++]); // LZW minimum code size
pos = CopySubBlocks(src, pos, outBytes); // image data
}
else
{
// Unknown — bail out and keep the original (safe fallback).
return src;
}
}
return outBytes.ToArray();
}
catch
{
return src;
}
}
private static int CopySubBlocks(byte[] src, int pos, List<byte> outBytes)
{
while (true)
{
byte size = src[pos];
outBytes.Add(size);
pos++;
if (size == 0) break;
outBytes.AddRange(src[pos..(pos + size)]);
pos += size;
}
return pos;
}
private static byte[] NetscapeLoopBlock()
{
var b = new List<byte> { 0x21, 0xFF, 0x0B };
b.AddRange(Encoding.ASCII.GetBytes("NETSCAPE2.0"));
b.AddRange(new byte[] { 0x03, 0x01, 0x00, 0x00, 0x00 }); // sub-block, id=1, loop count 0 = forever, terminator
return b.ToArray();
}
private static byte[] GraphicControlExtension(byte delayCs) =>
// introducer, label, block size, packed (disposal=1), delay lo/hi, transparent index, terminator
new byte[] { 0x21, 0xF9, 0x04, 0x04, delayCs, 0x00, 0x00, 0x00 };
private static (int w, int h) TargetSize(string firstFramePath)
{
var probe = new BitmapImage();
probe.BeginInit();
probe.UriSource = new Uri(firstFramePath);
probe.CacheOption = BitmapCacheOption.OnLoad;
probe.EndInit();
int h = (int)Math.Round(TargetWidth * (probe.PixelHeight / (double)probe.PixelWidth));
return (TargetWidth, Math.Max(1, h));
}
private static BitmapSource ToUniformFrame(string path, int w, int h)
{
var src = new BitmapImage();
src.BeginInit();
src.UriSource = new Uri(path);
src.CacheOption = BitmapCacheOption.OnLoad;
src.DecodePixelWidth = w;
src.EndInit();
src.Freeze();
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
dc.DrawImage(src, new Rect(0, 0, w, h));
var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
rtb.Render(visual);
rtb.Freeze();
return rtb;
}
}

View File

@ -0,0 +1,187 @@
using System.IO;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
using CamBooth.App.Features.Camera;
using CamBooth.App.Features.PhotoPrismUpload;
using CamBooth.App.Features.PictureGallery;
namespace CamBooth.App.Features.Capture;
/// <summary>
/// Orchestrates a capture session: one shot for <see cref="CaptureMode.Single"/>, or a timed burst
/// composited into a strip / animated GIF. Keeps the file/gallery/upload concerns out of the UI.
/// </summary>
public sealed class PhotoCaptureCoordinator
{
private const int DownloadTimeoutMs = 15_000;
private readonly Logger _logger;
private readonly AppSettingsService _appSettings;
private readonly CameraService _cameraService;
private readonly PictureGalleryService _pictureGalleryService;
private readonly PhotoPrismUploadQueueService _uploadQueueService;
private TaskCompletionSource<string>? _pendingShot;
private bool _capturing;
/// <summary>Raised (UI thread) at the moment each shot is taken — drive the shutter flash here.</summary>
public event Action? ShutterFlashRequested;
/// <summary>Raised (UI thread) before each burst shot: (currentShot, totalShots).</summary>
public event Action<int, int>? BurstProgressChanged;
public PhotoCaptureCoordinator(
Logger logger,
AppSettingsService appSettings,
CameraService cameraService,
PictureGalleryService pictureGalleryService,
PhotoPrismUploadQueueService uploadQueueService)
{
_logger = logger;
_appSettings = appSettings;
_cameraService = cameraService;
_pictureGalleryService = pictureGalleryService;
_uploadQueueService = uploadQueueService;
_cameraService.PhotoSaved += OnPhotoSaved;
}
/// <summary>
/// Runs a full capture session and returns the result to present, or null if it failed
/// (e.g. the camera never delivered a photo).
/// </summary>
public async Task<CaptureResult?> CaptureAsync()
{
if (_capturing)
{
_logger.Warning("Capture already in progress — ignoring re-entrant request.");
return null;
}
_capturing = true;
try
{
var mode = ParseMode(_appSettings.CaptureMode);
int count = mode == CaptureMode.Single ? 1 : Math.Max(1, _appSettings.BurstCount);
var frames = new List<string>();
for (int i = 0; i < count; i++)
{
BurstProgressChanged?.Invoke(i + 1, count);
frames.Add(await CaptureOneAsync(DownloadTimeoutMs));
if (i < count - 1)
await Task.Delay(_appSettings.BurstIntervalMs);
}
if (mode == CaptureMode.Single)
return new CaptureResult { ResultPath = frames[0], FramePaths = frames, Mode = CaptureMode.Single };
return Composite(mode, frames);
}
catch (Exception ex)
{
_logger.Error($"Capture failed: {ex.Message}");
return null;
}
finally
{
_capturing = false;
}
}
/// <summary>Accepts the result: adds it to the gallery and queues the upload.</summary>
public void Keep(CaptureResult result)
{
try
{
_pictureGalleryService.IncrementNewPhotoCount();
_ = _pictureGalleryService.LoadThumbnailsToCache();
_uploadQueueService.QueueNewPhoto(result.ResultPath);
}
catch (Exception ex)
{
_logger.Error($"Failed to keep photo: {ex.Message}");
}
}
/// <summary>Rejects the result: deletes the produced file(s) so nothing is shown or uploaded.</summary>
public void Discard(CaptureResult result)
{
var paths = new List<string>(result.FramePaths) { result.ResultPath };
foreach (var path in paths.Distinct())
TryDelete(path);
}
private CaptureResult Composite(CaptureMode mode, List<string> frames)
{
var location = _appSettings.PictureLocation!;
try
{
string outPath;
if (mode == CaptureMode.Strip)
{
outPath = Path.Combine(location, $"strip_{Guid.NewGuid()}.jpg");
PhotoCompositor.CreateStrip(frames, outPath, BuildBranding());
}
else
{
outPath = Path.Combine(location, $"gif_{Guid.NewGuid()}.gif");
GifEncoder.CreateGif(frames, outPath, _appSettings.BurstIntervalMs);
}
// Raw frames are only inputs — keep just the composite in the gallery/upload.
foreach (var f in frames)
TryDelete(f);
return new CaptureResult { ResultPath = outPath, FramePaths = Array.Empty<string>(), Mode = mode };
}
catch (Exception ex)
{
_logger.Error($"Compositing ({mode}) failed, falling back to single photo: {ex.Message}");
for (int i = 1; i < frames.Count; i++)
TryDelete(frames[i]);
return new CaptureResult { ResultPath = frames[0], FramePaths = new[] { frames[0] }, Mode = CaptureMode.Single };
}
}
private StripBrandingOptions BuildBranding() => new()
{
FooterText = _appSettings.StripFooterText.Replace("{date}", DateTime.Now.ToString("dd.MM.yyyy")),
LogoPath = _appSettings.StripLogoPath,
FooterColorHex = _appSettings.StripFooterColor,
BackgroundColorHex = _appSettings.StripBackgroundColor,
FontFamily = _appSettings.StripFooterFont,
FontSize = _appSettings.StripFooterFontSize,
FooterHeight = _appSettings.StripFooterHeight
};
private async Task<string> CaptureOneAsync(int timeoutMs)
{
_pendingShot = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
ShutterFlashRequested?.Invoke();
_cameraService.TakePhoto();
var completed = await Task.WhenAny(_pendingShot.Task, Task.Delay(timeoutMs));
if (completed != _pendingShot.Task)
throw new TimeoutException("Camera did not deliver a photo in time.");
return await _pendingShot.Task;
}
private void OnPhotoSaved(string path) => _pendingShot?.TrySetResult(path);
private void TryDelete(string path)
{
try
{
if (File.Exists(path)) File.Delete(path);
}
catch (Exception ex)
{
_logger.Warning($"Could not delete '{Path.GetFileName(path)}': {ex.Message}");
}
}
private static CaptureMode ParseMode(string value) =>
Enum.TryParse<CaptureMode>(value, ignoreCase: true, out var mode) ? mode : CaptureMode.Single;
}

View File

@ -0,0 +1,144 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace CamBooth.App.Features.Capture;
/// <summary>
/// Builds a classic vertical photo strip (montage) from several captured frames.
/// Pure WPF imaging — no external dependencies.
/// </summary>
public static class PhotoCompositor
{
private const int CellWidth = 600; // width each photo is scaled to (px)
private const int Border = 24; // white border / gap between photos (px)
private const double LogoPadding = 12; // padding around a footer logo (px)
/// <summary>
/// Composites <paramref name="framePaths"/> into a vertical strip and writes it as JPEG to
/// <paramref name="outputPath"/>. Returns <paramref name="outputPath"/> on success.
/// </summary>
public static string CreateStrip(IReadOnlyList<string> framePaths, string outputPath, StripBrandingOptions branding)
{
if (framePaths.Count == 0)
throw new ArgumentException("No frames to composite.", nameof(framePaths));
var frames = framePaths.Select(LoadFrozen).ToList();
// Each photo keeps its aspect ratio, scaled to CellWidth.
var cellHeights = frames
.Select(f => (int)Math.Round(CellWidth * (f.PixelHeight / (double)f.PixelWidth)))
.ToList();
var logo = LoadLogo(branding.LogoPath);
var hasText = !string.IsNullOrWhiteSpace(branding.FooterText);
var hasFooter = logo != null || hasText;
var footerBand = hasFooter ? branding.FooterHeight : 0;
var totalWidth = CellWidth + 2 * Border;
var totalHeight = Border + cellHeights.Sum() + Border * frames.Count + footerBand;
var background = new SolidColorBrush(ParseColor(branding.BackgroundColorHex, Colors.White));
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
dc.DrawRectangle(background, null, new Rect(0, 0, totalWidth, totalHeight));
double y = Border;
for (int i = 0; i < frames.Count; i++)
{
dc.DrawImage(frames[i], new Rect(Border, y, CellWidth, cellHeights[i]));
y += cellHeights[i] + Border;
}
if (hasFooter)
{
var footerTop = totalHeight - branding.FooterHeight;
if (logo != null)
DrawLogo(dc, logo, footerTop, totalWidth, branding.FooterHeight);
else
DrawFooterText(dc, visual, branding, footerTop, totalWidth);
}
}
var rtb = new RenderTargetBitmap(totalWidth, totalHeight, 96, 96, PixelFormats.Pbgra32);
rtb.Render(visual);
var encoder = new JpegBitmapEncoder { QualityLevel = 92 };
encoder.Frames.Add(BitmapFrame.Create(rtb));
using var output = new FileStream(outputPath, FileMode.Create, FileAccess.Write);
encoder.Save(output);
return outputPath;
}
private static void DrawFooterText(DrawingContext dc, DrawingVisual visual, StripBrandingOptions branding, double footerTop, int totalWidth)
{
var formatted = new FormattedText(
branding.FooterText!,
System.Globalization.CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface(new FontFamily(branding.FontFamily), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal),
branding.FontSize,
new SolidColorBrush(ParseColor(branding.FooterColorHex, Color.FromRgb(0xD4, 0xAF, 0x37))),
VisualTreeHelper.GetDpi(visual).PixelsPerDip);
var origin = new Point((totalWidth - formatted.Width) / 2, footerTop + (branding.FooterHeight - formatted.Height) / 2);
dc.DrawText(formatted, origin);
}
private static void DrawLogo(DrawingContext dc, BitmapSource logo, double footerTop, int totalWidth, int footerHeight)
{
var maxHeight = footerHeight - 2 * LogoPadding;
var maxWidth = totalWidth - 2 * LogoPadding;
var scale = Math.Min(maxHeight / logo.PixelHeight, maxWidth / logo.PixelWidth);
var w = logo.PixelWidth * scale;
var h = logo.PixelHeight * scale;
var x = (totalWidth - w) / 2;
var yy = footerTop + (footerHeight - h) / 2;
dc.DrawImage(logo, new Rect(x, yy, w, h));
}
private static BitmapSource? LoadLogo(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return null;
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
return bmp;
}
private static Color ParseColor(string hex, Color fallback)
{
try
{
var converted = ColorConverter.ConvertFromString(hex);
return converted is Color c ? c : fallback;
}
catch
{
return fallback;
}
}
private static BitmapSource LoadFrozen(string path)
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = CellWidth;
bmp.EndInit();
bmp.Freeze();
return bmp;
}
}

View File

@ -1,29 +0,0 @@
<UserControl x:Class="CamBooth.App.Features.LiveView.ModernTimerControl"
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"
mc:Ignorable="d"
d:DesignHeight="400" d:DesignWidth="400">
<Grid>
<!-- Hintergrund für den Timer -->
<Border CornerRadius="20" Background="#1E1E1E" Padding="10" Opacity="60" x:Name="TimerContainer">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- Countdown-Anzeige -->
<TextBlock x:Name="TimerText"
FontSize="288"
Foreground="White"
HorizontalAlignment="Center"
Text="5"
Margin="0,10"/>
<!-- Status-Text -->
<TextBlock x:Name="StatusText"
FontSize="32"
Foreground="Gray"
HorizontalAlignment="Center"
Text="Skunkface machen" />
</StackPanel>
</Border>
</Grid>
</UserControl>

View File

@ -1,44 +0,0 @@
using System.Windows.Controls;
using System.Windows.Media.Animation;
using System.Windows.Threading;
namespace CamBooth.App.Features.LiveView;
public partial class ModernTimerControl : UserControl
{
private DispatcherTimer _timer;
private int _remainingTime; // Zeit in Sekunden
public ModernTimerControl()
{
InitializeComponent();
InitializeTimer();
}
private void InitializeTimer()
{
_timer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(1)
};
_timer.Tick += Timer_Tick;
}
private void Timer_Tick(object sender, EventArgs e)
{
if (_remainingTime > 0)
{
_remainingTime--;
TimerText.Text = TimeSpan.FromSeconds(_remainingTime).ToString(@"mm\:ss");
}
else
{
_timer.Stop();
StatusText.Text = "Zeit abgelaufen!";
}
}
}

View File

@ -50,6 +50,21 @@
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<!-- Burst progress (only shown for Strip/Gif capture modes) -->
<Border x:Name="BurstProgressBadge"
Background="#CC101010"
CornerRadius="16"
Padding="28,12"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,120"
Visibility="Collapsed">
<TextBlock x:Name="BurstProgressText"
Foreground="#D4AF37"
FontSize="40"
FontWeight="Bold"
TextAlignment="Center"/>
</Border>
</Grid>
<!-- Action Buttons Container (bottom-right) -->
@ -339,52 +354,6 @@
</StackPanel>
</Border>
<!-- Shutdown Slider (bottom-left) -->
<!-- <Grid Grid.Row="0" -->
<!-- x:Name="ShutdownDock" -->
<!-- HorizontalAlignment="Left" -->
<!-- VerticalAlignment="Bottom" -->
<!-- Margin="20" -->
<!-- Panel.ZIndex="3" -->
<!-- Visibility="Hidden"> -->
<!-- <Grid.ColumnDefinitions> -->
<!-- <ColumnDefinition Width="Auto" /> -->
<!-- <ColumnDefinition Width="Auto" /> -->
<!-- </Grid.ColumnDefinitions> -->
<!-- -->
<!-- <ui:Button Grid.Column="0" -->
<!-- x:Name="ShutdownToggleButton" -->
<!-- Content="&#xE7E8;" -->
<!-- FontFamily="Segoe MDL2 Assets" -->
<!-- FontSize="28" -->
<!-- Width="64" -->
<!-- Height="64" -->
<!-- Appearance="Danger" -->
<!-- Click="ToggleShutdownSlider" /> -->
<!-- -->
<!-- <Border Grid.Column="1" -->
<!-- Width="160" -->
<!-- Height="64" -->
<!-- Margin="8 0 0 0" -->
<!-- CornerRadius="10" -->
<!-- Background="#44202020" -->
<!-- ClipToBounds="True"> -->
<!-- <Grid RenderTransformOrigin="0.5,0.5"> -->
<!-- <Grid.RenderTransform> -->
<!-- <TranslateTransform x:Name="ShutdownSliderTransform" X="160" /> -->
<!-- </Grid.RenderTransform> -->
<!-- <ui:Button x:Name="ShutdownConfirmButton" -->
<!-- Content="&#xE7E8;" -->
<!-- FontFamily="Segoe MDL2 Assets" -->
<!-- FontSize="24" -->
<!-- Appearance="Danger" -->
<!-- Width="160" -->
<!-- Height="64" -->
<!-- Click="ShutdownWindows" /> -->
<!-- </Grid> -->
<!-- </Border> -->
<!-- </Grid> -->
<!-- Flash overlay: briefly flashes white on shutter release -->
<Grid x:Name="FlashOverlay"
Grid.RowSpan="2"
@ -544,5 +513,69 @@
MaxWidth="600"/>
</core:BaseDialogOverlay>
<!-- Idle / Attract slideshow (#7) -->
<Grid x:Name="AttractOverlay"
Grid.RowSpan="2"
Background="Black"
Panel.ZIndex="100"
Visibility="Collapsed">
<Image x:Name="AttractImage" Stretch="UniformToFill" Opacity="0"/>
<Border VerticalAlignment="Bottom"
HorizontalAlignment="Center"
Margin="0,0,0,90"
Background="#99000000"
CornerRadius="18"
Padding="40,18">
<TextBlock Text="Tippe, um zu starten ✨"
Foreground="White"
FontSize="40"
FontWeight="Bold"
TextAlignment="Center"/>
</Border>
</Grid>
<!-- Photo review (#6) -->
<Grid x:Name="ReviewOverlay"
Grid.RowSpan="2"
Background="#EE000000"
Panel.ZIndex="150"
Visibility="Collapsed">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Border CornerRadius="16"
BorderBrush="#D4AF37"
BorderThickness="3"
Background="#1A1A1A"
Padding="6"
HorizontalAlignment="Center">
<Image x:Name="ReviewImage" MaxHeight="600" MaxWidth="1100" Stretch="Uniform"/>
</Border>
<TextBlock x:Name="ReviewCountdownText"
Foreground="#FFE8E8E8"
FontSize="20"
TextAlignment="Center"
Margin="0,16,0,18"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button x:Name="ReviewRetakeButton"
Content="🔄 Nochmal"
Click="ReviewRetake_Click"
Style="{StaticResource SecondaryButtonStyle}"
Width="280" Height="84" FontSize="28"
Margin="0,0,20,0"/>
<Button x:Name="ReviewKeepButton"
Content="👍 Behalten"
Click="ReviewKeep_Click"
Style="{StaticResource PrimaryActionButtonStyle}"
Width="280" Height="84" FontSize="28"/>
</StackPanel>
</StackPanel>
</Grid>
<!-- Confetti (#10) -->
<Canvas x:Name="ConfettiCanvas"
Grid.RowSpan="2"
Panel.ZIndex="160"
IsHitTestVisible="False"
ClipToBounds="True"/>
</Grid>
</Window>

View File

@ -1,13 +1,16 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.Windows.Threading;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
using CamBooth.App.Features.Camera;
using CamBooth.App.Features.Capture;
using CamBooth.App.Features.DebugConsole;
using CamBooth.App.Features.LiveView;
using CamBooth.App.Features.PhotoPrismUpload;
@ -25,17 +28,27 @@ public partial class MainWindow : Window
private readonly PictureGalleryService _pictureGalleryService;
private readonly CameraService _cameraService;
private readonly PhotoPrismUploadService _photoPrismUploadService;
private readonly PhotoCaptureCoordinator _captureCoordinator;
private readonly MainWindowViewModel _viewModel;
private LiveViewPage? _liveViewPage;
private bool _isCameraStarted;
private bool _isPicturePanelVisible;
private bool _isDebugConsoleVisible;
private bool _isShutdownSliderOpen;
private const string ShutdownGlyphClosed = "\uE7E8";
private const string ShutdownGlyphOpen = "\uE711";
private const double ShutdownSliderOffset = 160;
// Photo review (#6)
private TaskCompletionSource<bool>? _reviewTcs;
private DispatcherTimer? _reviewTimer;
private int _reviewRemainingSeconds;
// Idle / attract slideshow (#7)
private DispatcherTimer? _idleTimer;
private DispatcherTimer? _attractTimer;
private int _attractIndex;
private bool _attractActive;
// Confetti (#10)
private readonly Random _confettiRandom = new();
public MainWindow(
@ -44,6 +57,7 @@ public partial class MainWindow : Window
PictureGalleryService pictureGalleryService,
CameraService cameraService,
PhotoPrismUploadService photoPrismUploadService,
PhotoCaptureCoordinator captureCoordinator,
MainWindowViewModel viewModel)
{
_logger = logger;
@ -51,6 +65,7 @@ public partial class MainWindow : Window
_pictureGalleryService = pictureGalleryService;
_cameraService = cameraService;
_photoPrismUploadService = photoPrismUploadService;
_captureCoordinator = captureCoordinator;
_viewModel = viewModel;
InitializeComponent();
@ -73,6 +88,13 @@ public partial class MainWindow : Window
_pictureGalleryService.NewPhotoCountChanged += OnNewPhotoCountChanged;
TimerControlRectangleAnimation.OnTimerEllapsed += OnTimerElapsed;
// Capture coordinator → UI feedback
_captureCoordinator.ShutterFlashRequested += TriggerShutterFlash;
_captureCoordinator.BurstProgressChanged += OnBurstProgressChanged;
// Idle / attract slideshow
SetupIdleWatcher();
// Initial UI state
_isDebugConsoleVisible = _appSettings.IsDebugConsoleVisible;
SetVisibilityDebugConsole(_isDebugConsoleVisible);
@ -90,20 +112,63 @@ public partial class MainWindow : Window
#region Event handlers
private void OnTimerElapsed()
{
SwitchButtonAndTimerPanel();
try
private async void OnTimerElapsed()
{
// Focus is guaranteed complete before the timer fires (bounded timeout).
_viewModel.TakePhotoAfterTimer();
TriggerShutterFlash();
try
{
var result = await _captureCoordinator.CaptureAsync();
if (result == null)
{
System.Windows.MessageBox.Show("Sorry, da ging was schief! Bitte nochmal probieren.");
return;
}
var keep = !_appSettings.IsReviewEnabled || await ShowReviewAsync(result);
if (keep)
{
_captureCoordinator.Keep(result);
if (_appSettings.IsConfettiEnabled) TriggerConfetti();
_viewModel.ShowGalleryPrompt();
}
else
{
_captureCoordinator.Discard(result);
}
}
catch (Exception ex)
{
_logger.Error(ex.Message);
System.Windows.MessageBox.Show("Sorry, da ging was schief! Bitte nochmal probieren.");
}
finally
{
_viewModel.FinishPhotoProcess();
HideBurstProgress();
ShowCaptureButtons();
}
}
private void OnBurstProgressChanged(int current, int total)
{
// Only meaningful for multi-shot modes (Strip/Gif).
if (total <= 1)
{
HideBurstProgress();
return;
}
BurstProgressText.Text = $"Foto {current} von {total}";
BurstProgressBadge.Visibility = Visibility.Visible;
}
private void HideBurstProgress() => BurstProgressBadge.Visibility = Visibility.Collapsed;
private void ShowCaptureButtons()
{
ButtonPanel.Visibility = Visibility.Visible;
ActionButtonsContainer.Visibility = Visibility.Visible;
TimerPanel.Visibility = Visibility.Hidden;
}
private void OnNewPhotoCountChanged(object? sender, int count)
@ -134,7 +199,6 @@ public partial class MainWindow : Window
WelcomeOverlay.Visibility = Visibility.Collapsed;
ButtonPanel.Visibility = Visibility.Visible;
ActionButtonsContainer.Visibility = Visibility.Visible;
//ShutdownDock.Visibility = Visibility.Visible;
}
private void StartTakePhotoProcess(object sender, RoutedEventArgs e)
@ -163,53 +227,6 @@ public partial class MainWindow : Window
SetVisibilityDebugConsole(_isDebugConsoleVisible);
}
// private void ToggleShutdownSlider(object sender, RoutedEventArgs e)
// {
// _isShutdownSliderOpen = !_isShutdownSliderOpen;
// ShutdownToggleButton.Content = _isShutdownSliderOpen ? ShutdownGlyphOpen : ShutdownGlyphClosed;
// var animation = new DoubleAnimation
// {
// To = _isShutdownSliderOpen ? 0 : ShutdownSliderOffset,
// Duration = TimeSpan.FromMilliseconds(250),
// EasingFunction = new QuadraticEase()
// };
// ShutdownSliderTransform.BeginAnimation(TranslateTransform.XProperty, animation);
// }
// private async void ShutdownWindows(object sender, RoutedEventArgs e)
// {
// bool confirmed = await ShutdownConfirmOverlay.ShowAsync();
// if (!confirmed) return;
//
// try
// {
// _cameraService.CloseSession();
// }
// catch
// {
// }
//
// var (args, errorMsg) = _appSettings.IsShutdownEnabled
// ? ("/s /t 0", "Windows konnte nicht heruntergefahren werden.")
// : ("/l", "Abmeldung fehlgeschlagen.");
//
// try
// {
// Process.Start(new ProcessStartInfo
// {
// FileName = "shutdown",
// Arguments = args,
// CreateNoWindow = true,
// UseShellExecute = false
// });
// }
// catch (Exception ex)
// {
// _logger.Error(ex.Message);
// System.Windows.MessageBox.Show(errorMsg);
// }
// }
private void ShowQRCode(object sender, MouseButtonEventArgs e)
{
try
@ -306,14 +323,12 @@ public partial class MainWindow : Window
_pictureGalleryService.ResetNewPhotoCount();
ButtonPanel.Visibility = Visibility.Hidden;
ActionButtonsContainer.Visibility = Visibility.Hidden;
//ShutdownDock.Visibility = Visibility.Hidden;
}
else
{
PicturePanel.ClearValue(ContentProperty);
ButtonPanel.Visibility = Visibility.Visible;
ActionButtonsContainer.Visibility = Visibility.Visible;
//ShutdownDock.Visibility = Visibility.Visible;
}
_isPicturePanelVisible = visible;
@ -377,4 +392,235 @@ public partial class MainWindow : Window
}
#endregion
#region Photo review (#6)
/// <summary>Shows the captured result with Keep/Retake and auto-keeps after a timeout.</summary>
private Task<bool> ShowReviewAsync(CaptureResult result)
{
_reviewTcs = new TaskCompletionSource<bool>();
try
{
ReviewImage.Source = LoadDisplayImage(new Uri(result.ResultPath));
}
catch (Exception ex)
{
_logger.Error($"Could not load review image: {ex.Message}");
// Nothing to review — accept silently.
return Task.FromResult(true);
}
_reviewRemainingSeconds = Math.Max(2, _appSettings.ReviewTimeoutSeconds);
UpdateReviewCountdown();
_reviewTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_reviewTimer.Tick += ReviewTimer_Tick;
_reviewTimer.Start();
ReviewOverlay.Visibility = Visibility.Visible;
return _reviewTcs.Task;
}
private void ReviewTimer_Tick(object? sender, EventArgs e)
{
_reviewRemainingSeconds--;
if (_reviewRemainingSeconds <= 0)
CompleteReview(true);
else
UpdateReviewCountdown();
}
private void UpdateReviewCountdown() =>
ReviewCountdownText.Text = $"Wird in {_reviewRemainingSeconds}s automatisch behalten …";
private void ReviewKeep_Click(object sender, RoutedEventArgs e) => CompleteReview(true);
private void ReviewRetake_Click(object sender, RoutedEventArgs e) => CompleteReview(false);
private void CompleteReview(bool keep)
{
_reviewTimer?.Stop();
_reviewTimer = null;
ReviewOverlay.Visibility = Visibility.Collapsed;
ReviewImage.Source = null;
_reviewTcs?.TrySetResult(keep);
_reviewTcs = null;
}
#endregion
#region Confetti (#10)
/// <summary>Rains colored confetti from the top of the window once.</summary>
private void TriggerConfetti()
{
ConfettiCanvas.Children.Clear();
var width = ConfettiCanvas.ActualWidth > 0 ? ConfettiCanvas.ActualWidth : ActualWidth;
var height = ConfettiCanvas.ActualHeight > 0 ? ConfettiCanvas.ActualHeight : ActualHeight;
if (width <= 0 || height <= 0) return;
var colors = new[]
{
Color.FromRgb(0xD4, 0xAF, 0x37), Color.FromRgb(0xE6, 0x39, 0x46),
Color.FromRgb(0x2A, 0x9D, 0x8F), Color.FromRgb(0x45, 0x7B, 0x9D),
Color.FromRgb(0xF4, 0xA2, 0x61), Color.FromRgb(0xE9, 0xC4, 0x6A),
Color.FromRgb(0xFF, 0xFF, 0xFF)
};
const int pieceCount = 90;
for (var i = 0; i < pieceCount; i++)
{
var size = _confettiRandom.Next(8, 18);
var piece = new Rectangle
{
Width = size,
Height = size * (_confettiRandom.Next(0, 2) == 0 ? 1 : 0.5),
Fill = new SolidColorBrush(colors[_confettiRandom.Next(colors.Length)]),
RadiusX = 2,
RadiusY = 2
};
Canvas.SetLeft(piece, _confettiRandom.NextDouble() * width);
Canvas.SetTop(piece, -30);
var rotate = new RotateTransform();
var translate = new TranslateTransform();
var group = new TransformGroup();
group.Children.Add(rotate);
group.Children.Add(translate);
piece.RenderTransform = group;
piece.RenderTransformOrigin = new Point(0.5, 0.5);
ConfettiCanvas.Children.Add(piece);
var fallMs = _confettiRandom.Next(1800, 3300);
var fall = new DoubleAnimation(0, height + 80, TimeSpan.FromMilliseconds(fallMs))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn }
};
fall.Completed += (_, _) => ConfettiCanvas.Children.Remove(piece);
var drift = new DoubleAnimation(0, _confettiRandom.Next(-140, 140), TimeSpan.FromMilliseconds(fallMs));
var spin = new DoubleAnimation(0, _confettiRandom.Next(180, 900), TimeSpan.FromMilliseconds(fallMs));
var fade = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(fallMs * 0.4))
{
BeginTime = TimeSpan.FromMilliseconds(fallMs * 0.6)
};
translate.BeginAnimation(TranslateTransform.YProperty, fall);
translate.BeginAnimation(TranslateTransform.XProperty, drift);
rotate.BeginAnimation(RotateTransform.AngleProperty, spin);
piece.BeginAnimation(OpacityProperty, fade);
}
}
#endregion
#region Idle / attract slideshow (#7)
private void SetupIdleWatcher()
{
if (_appSettings.IdleTimeoutSeconds <= 0) return;
_idleTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(_appSettings.IdleTimeoutSeconds) };
_idleTimer.Tick += (_, _) => ShowAttractScreen();
PreviewMouseDown += (_, _) => OnUserActivity();
PreviewKeyDown += (_, _) => OnUserActivity();
PreviewTouchDown += (_, _) => OnUserActivity();
_idleTimer.Start();
}
private void OnUserActivity()
{
if (_attractActive) HideAttractScreen();
_idleTimer?.Stop();
_idleTimer?.Start();
}
private void ShowAttractScreen()
{
// Don't interrupt setup, capture, review or open dialogs.
if (WelcomeOverlay.Visibility == Visibility.Visible) return;
if (LoadingOverlay.Visibility == Visibility.Visible) return;
if (ReviewOverlay.Visibility == Visibility.Visible) return;
if (QRCodeOverlay.Visibility == Visibility.Visible) return;
if (_viewModel.IsPhotoProcessRunning) return;
var photos = _pictureGalleryService.ThumbnailsOrderedByNewestDescending;
if (photos.Count == 0) return;
_idleTimer?.Stop();
_attractActive = true;
_attractIndex = 0;
AttractOverlay.Visibility = Visibility.Visible;
ShowAttractImage(photos[0]);
_attractTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(4) };
_attractTimer.Tick += AttractTimer_Tick;
_attractTimer.Start();
}
private void AttractTimer_Tick(object? sender, EventArgs e)
{
var photos = _pictureGalleryService.ThumbnailsOrderedByNewestDescending;
if (photos.Count == 0)
{
HideAttractScreen();
return;
}
_attractIndex = (_attractIndex + 1) % photos.Count;
ShowAttractImage(photos[_attractIndex]);
}
private void ShowAttractImage(BitmapImage thumbnail)
{
try
{
ImageSource source = thumbnail.UriSource != null
? LoadDisplayImage(thumbnail.UriSource)
: thumbnail;
AttractImage.Source = source;
AttractImage.BeginAnimation(OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(700)));
}
catch (Exception ex)
{
_logger.Error($"Attract image failed: {ex.Message}");
}
}
private void HideAttractScreen()
{
_attractTimer?.Stop();
_attractTimer = null;
_attractActive = false;
AttractOverlay.Visibility = Visibility.Collapsed;
AttractImage.Source = null;
_idleTimer?.Start();
}
/// <summary>Loads a full-resolution, frozen image from disk, decoded to roughly screen width.</summary>
private ImageSource LoadDisplayImage(Uri uri)
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = uri;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = (int)Math.Max(800, ActualWidth);
bmp.EndInit();
bmp.Freeze();
return bmp;
}
#endregion
}

View File

@ -91,14 +91,12 @@ public class MainWindowViewModel
}
/// <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.
/// Marks the photo process as finished. The actual capture is driven by the
/// <see cref="Features.Capture.PhotoCaptureCoordinator"/> from the UI layer.
/// </summary>
public void TakePhotoAfterTimer()
public void FinishPhotoProcess()
{
IsPhotoProcessRunning = false;
_cameraService.TakePhoto();
ShowGalleryPrompt();
}