"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.AppSettings;
|
||||||
using CamBooth.App.Core.Logging;
|
using CamBooth.App.Core.Logging;
|
||||||
using CamBooth.App.Features.Camera;
|
using CamBooth.App.Features.Camera;
|
||||||
|
using CamBooth.App.Features.Capture;
|
||||||
using CamBooth.App.Features.PhotoPrismUpload;
|
using CamBooth.App.Features.PhotoPrismUpload;
|
||||||
using CamBooth.App.Features.PictureGallery;
|
using CamBooth.App.Features.PictureGallery;
|
||||||
using EDSDKLib.API.Base;
|
using EDSDKLib.API.Base;
|
||||||
@ -89,6 +90,7 @@ public partial class App : Application
|
|||||||
services.AddSingleton<AppSettingsService>();
|
services.AddSingleton<AppSettingsService>();
|
||||||
services.AddSingleton<PictureGalleryService>();
|
services.AddSingleton<PictureGalleryService>();
|
||||||
services.AddSingleton<CameraService>();
|
services.AddSingleton<CameraService>();
|
||||||
|
services.AddSingleton<PhotoCaptureCoordinator>();
|
||||||
services.AddSingleton<PhotoPrismAuthService>();
|
services.AddSingleton<PhotoPrismAuthService>();
|
||||||
services.AddSingleton<PhotoPrismUploadService>();
|
services.AddSingleton<PhotoPrismUploadService>();
|
||||||
services.AddSingleton<PhotoPrismUploadQueueService>();
|
services.AddSingleton<PhotoPrismUploadQueueService>();
|
||||||
|
|||||||
@ -44,7 +44,7 @@ public class AppSettingsService
|
|||||||
|
|
||||||
public string? AppName => configuration["AppSettings:AppName"];
|
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"];
|
public string? PictureLocation => configuration["AppSettings:PictureLocation"];
|
||||||
|
|
||||||
@ -58,6 +58,50 @@ public class AppSettingsService
|
|||||||
|
|
||||||
public bool UseMockCamera => bool.Parse(configuration["AppSettings:UseMockCamera"] ?? "false");
|
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? ConnectionString => configuration.GetConnectionString("DefaultConnection");
|
||||||
|
|
||||||
public string ConfigFileName => loadedConfigFile;
|
public string ConfigFileName => loadedConfigFile;
|
||||||
@ -71,17 +115,6 @@ public class AppSettingsService
|
|||||||
|
|
||||||
public string? RemoteServerApiKey => configuration["LoggingSettings:RemoteServerApiKey"];
|
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
|
// PhotoPrism Upload Settings
|
||||||
public string? PhotoPrismApiUrl => configuration["PhotoPrismSettings:ApiUrl"];
|
public string? PhotoPrismApiUrl => configuration["PhotoPrismSettings:ApiUrl"];
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,21 @@
|
|||||||
"FocusDelaySeconds": 2,
|
"FocusDelaySeconds": 2,
|
||||||
"FocusTimeoutMs": 3000,
|
"FocusTimeoutMs": 3000,
|
||||||
"IsShutdownEnabled": false,
|
"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": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
|
|||||||
@ -4,8 +4,6 @@ using System.Threading.Tasks;
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using CamBooth.App.Core.AppSettings;
|
using CamBooth.App.Core.AppSettings;
|
||||||
using CamBooth.App.Core.Logging;
|
using CamBooth.App.Core.Logging;
|
||||||
using CamBooth.App.Features.PhotoPrismUpload;
|
|
||||||
using CamBooth.App.Features.PictureGallery;
|
|
||||||
using EOSDigital.API;
|
using EOSDigital.API;
|
||||||
using EOSDigital.SDK;
|
using EOSDigital.SDK;
|
||||||
|
|
||||||
@ -15,8 +13,6 @@ public class CameraService : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly AppSettingsService _appSettings;
|
private readonly AppSettingsService _appSettings;
|
||||||
private readonly Logger _logger;
|
private readonly Logger _logger;
|
||||||
private readonly PictureGalleryService _pictureGalleryService;
|
|
||||||
private readonly PhotoPrismUploadQueueService _photoPrismUploadQueueService;
|
|
||||||
private readonly ICanonAPI _canonApi;
|
private readonly ICanonAPI _canonApi;
|
||||||
|
|
||||||
private ICamera? _mainCamera;
|
private ICamera? _mainCamera;
|
||||||
@ -27,20 +23,23 @@ public class CameraService : IDisposable
|
|||||||
/// <summary>Fires whenever the camera delivers a new live-view frame.</summary>
|
/// <summary>Fires whenever the camera delivers a new live-view frame.</summary>
|
||||||
public event Action<Stream>? LiveViewUpdated;
|
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 bool IsConnected => _isConnected && _mainCamera?.SessionOpen == true;
|
||||||
|
|
||||||
|
|
||||||
public CameraService(
|
public CameraService(
|
||||||
Logger logger,
|
Logger logger,
|
||||||
AppSettingsService appSettings,
|
AppSettingsService appSettings,
|
||||||
PictureGalleryService pictureGalleryService,
|
|
||||||
PhotoPrismUploadQueueService photoPrismUploadQueueService,
|
|
||||||
ICanonAPI canonApi)
|
ICanonAPI canonApi)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_appSettings = appSettings;
|
_appSettings = appSettings;
|
||||||
_pictureGalleryService = pictureGalleryService;
|
|
||||||
_photoPrismUploadQueueService = photoPrismUploadQueueService;
|
|
||||||
_canonApi = canonApi;
|
_canonApi = canonApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,13 +291,9 @@ public class CameraService : IDisposable
|
|||||||
var savedPath = Path.Combine(_appSettings.PictureLocation!, info.FileName);
|
var savedPath = Path.Combine(_appSettings.PictureLocation!, info.FileName);
|
||||||
_logger.Debug($"Download complete: {savedPath}");
|
_logger.Debug($"Download complete: {savedPath}");
|
||||||
|
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
// 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).
|
||||||
_pictureGalleryService.IncrementNewPhotoCount();
|
Application.Current.Dispatcher.Invoke(() => PhotoSaved?.Invoke(savedPath));
|
||||||
_pictureGalleryService.LoadThumbnailsToCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
_photoPrismUploadQueueService.QueueNewPhoto(savedPath);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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"
|
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="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>
|
</Grid>
|
||||||
|
|
||||||
<!-- Action Buttons Container (bottom-right) -->
|
<!-- Action Buttons Container (bottom-right) -->
|
||||||
@ -339,52 +354,6 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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 -->
|
<!-- Flash overlay: briefly flashes white on shutter release -->
|
||||||
<Grid x:Name="FlashOverlay"
|
<Grid x:Name="FlashOverlay"
|
||||||
Grid.RowSpan="2"
|
Grid.RowSpan="2"
|
||||||
@ -544,5 +513,69 @@
|
|||||||
MaxWidth="600"/>
|
MaxWidth="600"/>
|
||||||
</core:BaseDialogOverlay>
|
</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>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Animation;
|
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.AppSettings;
|
||||||
using CamBooth.App.Core.Logging;
|
using CamBooth.App.Core.Logging;
|
||||||
using CamBooth.App.Features.Camera;
|
using CamBooth.App.Features.Camera;
|
||||||
|
using CamBooth.App.Features.Capture;
|
||||||
using CamBooth.App.Features.DebugConsole;
|
using CamBooth.App.Features.DebugConsole;
|
||||||
using CamBooth.App.Features.LiveView;
|
using CamBooth.App.Features.LiveView;
|
||||||
using CamBooth.App.Features.PhotoPrismUpload;
|
using CamBooth.App.Features.PhotoPrismUpload;
|
||||||
@ -25,17 +28,27 @@ public partial class MainWindow : Window
|
|||||||
private readonly PictureGalleryService _pictureGalleryService;
|
private readonly PictureGalleryService _pictureGalleryService;
|
||||||
private readonly CameraService _cameraService;
|
private readonly CameraService _cameraService;
|
||||||
private readonly PhotoPrismUploadService _photoPrismUploadService;
|
private readonly PhotoPrismUploadService _photoPrismUploadService;
|
||||||
|
private readonly PhotoCaptureCoordinator _captureCoordinator;
|
||||||
private readonly MainWindowViewModel _viewModel;
|
private readonly MainWindowViewModel _viewModel;
|
||||||
|
|
||||||
private LiveViewPage? _liveViewPage;
|
private LiveViewPage? _liveViewPage;
|
||||||
private bool _isCameraStarted;
|
private bool _isCameraStarted;
|
||||||
private bool _isPicturePanelVisible;
|
private bool _isPicturePanelVisible;
|
||||||
private bool _isDebugConsoleVisible;
|
private bool _isDebugConsoleVisible;
|
||||||
private bool _isShutdownSliderOpen;
|
|
||||||
|
|
||||||
private const string ShutdownGlyphClosed = "\uE7E8";
|
// Photo review (#6)
|
||||||
private const string ShutdownGlyphOpen = "\uE711";
|
private TaskCompletionSource<bool>? _reviewTcs;
|
||||||
private const double ShutdownSliderOffset = 160;
|
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(
|
public MainWindow(
|
||||||
@ -44,6 +57,7 @@ public partial class MainWindow : Window
|
|||||||
PictureGalleryService pictureGalleryService,
|
PictureGalleryService pictureGalleryService,
|
||||||
CameraService cameraService,
|
CameraService cameraService,
|
||||||
PhotoPrismUploadService photoPrismUploadService,
|
PhotoPrismUploadService photoPrismUploadService,
|
||||||
|
PhotoCaptureCoordinator captureCoordinator,
|
||||||
MainWindowViewModel viewModel)
|
MainWindowViewModel viewModel)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@ -51,6 +65,7 @@ public partial class MainWindow : Window
|
|||||||
_pictureGalleryService = pictureGalleryService;
|
_pictureGalleryService = pictureGalleryService;
|
||||||
_cameraService = cameraService;
|
_cameraService = cameraService;
|
||||||
_photoPrismUploadService = photoPrismUploadService;
|
_photoPrismUploadService = photoPrismUploadService;
|
||||||
|
_captureCoordinator = captureCoordinator;
|
||||||
_viewModel = viewModel;
|
_viewModel = viewModel;
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@ -73,6 +88,13 @@ public partial class MainWindow : Window
|
|||||||
_pictureGalleryService.NewPhotoCountChanged += OnNewPhotoCountChanged;
|
_pictureGalleryService.NewPhotoCountChanged += OnNewPhotoCountChanged;
|
||||||
TimerControlRectangleAnimation.OnTimerEllapsed += OnTimerElapsed;
|
TimerControlRectangleAnimation.OnTimerEllapsed += OnTimerElapsed;
|
||||||
|
|
||||||
|
// Capture coordinator → UI feedback
|
||||||
|
_captureCoordinator.ShutterFlashRequested += TriggerShutterFlash;
|
||||||
|
_captureCoordinator.BurstProgressChanged += OnBurstProgressChanged;
|
||||||
|
|
||||||
|
// Idle / attract slideshow
|
||||||
|
SetupIdleWatcher();
|
||||||
|
|
||||||
// Initial UI state
|
// Initial UI state
|
||||||
_isDebugConsoleVisible = _appSettings.IsDebugConsoleVisible;
|
_isDebugConsoleVisible = _appSettings.IsDebugConsoleVisible;
|
||||||
SetVisibilityDebugConsole(_isDebugConsoleVisible);
|
SetVisibilityDebugConsole(_isDebugConsoleVisible);
|
||||||
@ -90,20 +112,63 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
#region Event handlers
|
#region Event handlers
|
||||||
|
|
||||||
private void OnTimerElapsed()
|
private async void OnTimerElapsed()
|
||||||
{
|
{
|
||||||
SwitchButtonAndTimerPanel();
|
// Focus is guaranteed complete before the timer fires (bounded timeout).
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Focus is guaranteed complete before the timer fires (bounded timeout).
|
var result = await _captureCoordinator.CaptureAsync();
|
||||||
_viewModel.TakePhotoAfterTimer();
|
if (result == null)
|
||||||
TriggerShutterFlash();
|
{
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.Error(ex.Message);
|
_logger.Error(ex.Message);
|
||||||
System.Windows.MessageBox.Show("Sorry, da ging was schief! Bitte nochmal probieren.");
|
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)
|
private void OnNewPhotoCountChanged(object? sender, int count)
|
||||||
@ -134,7 +199,6 @@ public partial class MainWindow : Window
|
|||||||
WelcomeOverlay.Visibility = Visibility.Collapsed;
|
WelcomeOverlay.Visibility = Visibility.Collapsed;
|
||||||
ButtonPanel.Visibility = Visibility.Visible;
|
ButtonPanel.Visibility = Visibility.Visible;
|
||||||
ActionButtonsContainer.Visibility = Visibility.Visible;
|
ActionButtonsContainer.Visibility = Visibility.Visible;
|
||||||
//ShutdownDock.Visibility = Visibility.Visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartTakePhotoProcess(object sender, RoutedEventArgs e)
|
private void StartTakePhotoProcess(object sender, RoutedEventArgs e)
|
||||||
@ -163,53 +227,6 @@ public partial class MainWindow : Window
|
|||||||
SetVisibilityDebugConsole(_isDebugConsoleVisible);
|
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)
|
private void ShowQRCode(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -306,14 +323,12 @@ public partial class MainWindow : Window
|
|||||||
_pictureGalleryService.ResetNewPhotoCount();
|
_pictureGalleryService.ResetNewPhotoCount();
|
||||||
ButtonPanel.Visibility = Visibility.Hidden;
|
ButtonPanel.Visibility = Visibility.Hidden;
|
||||||
ActionButtonsContainer.Visibility = Visibility.Hidden;
|
ActionButtonsContainer.Visibility = Visibility.Hidden;
|
||||||
//ShutdownDock.Visibility = Visibility.Hidden;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
PicturePanel.ClearValue(ContentProperty);
|
PicturePanel.ClearValue(ContentProperty);
|
||||||
ButtonPanel.Visibility = Visibility.Visible;
|
ButtonPanel.Visibility = Visibility.Visible;
|
||||||
ActionButtonsContainer.Visibility = Visibility.Visible;
|
ActionButtonsContainer.Visibility = Visibility.Visible;
|
||||||
//ShutdownDock.Visibility = Visibility.Visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_isPicturePanelVisible = visible;
|
_isPicturePanelVisible = visible;
|
||||||
@ -377,4 +392,235 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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>
|
/// <summary>
|
||||||
/// Called when the timer fires. Focus is already guaranteed complete — takes photo immediately.
|
/// Marks the photo process as finished. The actual capture is driven by the
|
||||||
/// Throws on camera error so the caller can show a message.
|
/// <see cref="Features.Capture.PhotoCaptureCoordinator"/> from the UI layer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void TakePhotoAfterTimer()
|
public void FinishPhotoProcess()
|
||||||
{
|
{
|
||||||
IsPhotoProcessRunning = false;
|
IsPhotoProcessRunning = false;
|
||||||
_cameraService.TakePhoto();
|
|
||||||
ShowGalleryPrompt();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user