"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:
parent
9ad55bd90a
commit
0e4ecb3331
@ -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>();
|
||||
|
||||
@ -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"];
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
21
src/CamBooth/CamBooth.App/Features/Capture/CaptureMode.cs
Normal file
21
src/CamBooth/CamBooth.App/Features/Capture/CaptureMode.cs
Normal 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; }
|
||||
}
|
||||
180
src/CamBooth/CamBooth.App/Features/Capture/GifEncoder.cs
Normal file
180
src/CamBooth/CamBooth.App/Features/Capture/GifEncoder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
144
src/CamBooth/CamBooth.App/Features/Capture/PhotoCompositor.cs
Normal file
144
src/CamBooth/CamBooth.App/Features/Capture/PhotoCompositor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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!";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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="" -->
|
||||
<!-- 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="" -->
|
||||
<!-- 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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user