Compare commits

...

2 Commits

Author SHA1 Message Date
097de823c0 qr code 2026-03-02 19:39:25 +01:00
28ae78cea7 remote logging 2026-03-01 16:13:49 +01:00
24 changed files with 2258 additions and 334 deletions

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
<component name="Encoding" defaultCharsetForPropertiesFiles="UTF-8" />
</project>

View File

@ -0,0 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Publish CamBooth.App to folder" type="DotNetFolderPublish" factoryName="Publish to folder">
<riderPublish configuration="Release" platform="Any CPU" target_folder="$PROJECT_DIR$/CamBooth.App/bin/Release/net8.0-windows/publish" target_framework="net8.0-windows" uuid_high="-4544498340232084923" uuid_low="-5774556574018673293" />
<method v="2" />
</configuration>
</component>

View File

@ -1,5 +1,5 @@
using System.IO;
using System.Windows;
using System.IO;
using System.Windows;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
@ -11,6 +11,7 @@ using EDSDKLib.API.Base;
using EOSDigital.API;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace CamBooth.App;
@ -26,13 +27,31 @@ public partial class App : Application
{
base.OnStartup(e);
// Konfiguration laden
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
var configBuilder = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("Core/AppSettings/app.settings.json", optional: false, reloadOnChange: true);
if (environment == "Development")
{
configBuilder.AddJsonFile("Core/AppSettings/app.settings.dev.json", optional: true, reloadOnChange: true);
}
var configuration = configBuilder.Build();
var services = new ServiceCollection();
// Register Configuration
services.AddSingleton<IConfiguration>(configuration);
// Register base services
services.AddSingleton<Logger>();
services.AddSingleton<AppSettingsService>();
services.AddSingleton<PictureGalleryService>();
services.AddSingleton<LycheeUploadService>();
services.AddSingleton<UploadQueueService>();
services.AddSingleton<CameraService>();
// Zuerst den Provider bauen, um AppSettings zu laden
@ -86,7 +105,42 @@ public partial class App : Application
_serviceProvider = services.BuildServiceProvider();
// Starte UploadQueueService beim Start
try
{
var uploadQueueService = _serviceProvider.GetRequiredService<UploadQueueService>();
uploadQueueService.Start();
// Scan für fehlgeschlagene Uploads beim Start
uploadQueueService.ScanAndQueueFailedUploads();
logger.Info("UploadQueueService initialisiert und gestartet");
}
catch (Exception ex)
{
logger.Error($"Fehler beim Start des UploadQueueService: {ex.Message}");
}
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
{
// Stoppe UploadQueueService beim Beenden der App
try
{
var uploadQueueService = _serviceProvider?.GetService<UploadQueueService>();
if (uploadQueueService != null)
{
uploadQueueService.StopAsync().Wait(TimeSpan.FromSeconds(10));
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Fehler beim Stoppen des UploadQueueService: {ex.Message}");
}
base.OnExit(e);
}
}

View File

@ -9,55 +9,67 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<PlatformTarget>x86</PlatformTarget>
<PlatformTarget>x86</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<PlatformTarget>x86</PlatformTarget>
<PlatformTarget>x86</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\EDSDKLib\EDSDKLib.csproj" />
<ProjectReference Include="..\EDSDKLib\EDSDKLib.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.1" />
<PackageReference Include="WPF-UI" Version="4.0.0-rc.3" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.1"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.1"/>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.1"/>
<PackageReference Include="Serilog" Version="4.3.1"/>
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.Http" Version="9.0.0"/>
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
<PackageReference Include="WPF-UI" Version="4.0.0-rc.3"/>
<PackageReference Include="QRCoder" Version="1.4.3"/>
<!-- Material Design Icons -->
<PackageReference Include="MaterialDesignThemes" Version="5.1.0"/>
<PackageReference Include="MaterialDesignColors" Version="3.1.0"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Features\" />
<Folder Include="Features\"/>
</ItemGroup>
<ItemGroup>
<Compile Remove="artifacts\**\*.cs" />
<EmbeddedResource Remove="artifacts\**\*" />
<None Remove="artifacts\**\*" />
<Page Remove="artifacts\**\*.xaml" />
<Compile Remove="artifacts\**\*.cs"/>
<EmbeddedResource Remove="artifacts\**\*"/>
<None Remove="artifacts\**\*"/>
<Page Remove="artifacts\**\*.xaml"/>
</ItemGroup>
<ItemGroup>
<None Update="Core\AppSettings\app.settings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Core\AppSettings\app.settings.dev.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Core\AppSettings\app.settings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Core\AppSettings\app.settings.dev.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Page Update="Features\DebugConsole\DebugConsolePage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Features\LiveView\LiveViewPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Features\PictureGallery\PictureGalleryPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Features\DebugConsole\DebugConsolePage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Features\LiveView\LiveViewPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Update="Features\PictureGallery\PictureGalleryPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
</Project>

View File

@ -1,4 +1,4 @@
using CamBooth.App.Core.Logging;
using CamBooth.App.Core.Logging;
namespace CamBooth.App.Core.AppSettings;
@ -62,6 +62,15 @@ public class AppSettingsService
public string ConfigFileName => loadedConfigFile;
// Logging Settings
public string LogLevel => configuration["LoggingSettings:LogLevel"] ?? "Information";
public string LogDirectory => configuration["LoggingSettings:LogDirectory"] ?? "Logs";
public string? RemoteServerUrl => configuration["LoggingSettings:RemoteServerUrl"];
public string? RemoteServerApiKey => configuration["LoggingSettings:RemoteServerApiKey"];
// Lychee Upload Settings
public string? LycheeApiUrl => configuration["LycheeSettings:ApiUrl"];

View File

@ -11,11 +11,40 @@
"IsShutdownEnabled": false,
"UseMockCamera": true
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "File",
"Args": {
"path": "Logs/cambooth-dev-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
]
},
"LoggingSettings": {
"LogLevel": "Debug",
"LogDirectory": "Logs",
"RemoteServerUrl": "https://log.grimma-fotobox.de",
"RemoteServerApiKey": "nhnVql3QNgoAxvDWmNyU"
},
"LycheeSettings": {
"ApiUrl": "https://cambooth-pics.rblnews.de",
"ApiUrl": "https://gallery.grimma-fotobox.de",
"Username": "itob",
"Password": "VfVyqal&Nv8U&P",
"DefaultAlbumId": "gMM3W-Pk9mr8k57k-WU2Jz8t",
"DefaultAlbumId": "ukPbEHxdV_q1BOpIVKah7TUR",
"AutoUploadEnabled": true
},
"ConnectionStrings": {

View File

@ -8,15 +8,44 @@
"PhotoCountdownSeconds": 5,
"FocusDelaySeconds": 2,
"FocusTimeoutMs": 3000,
"IsShutdownEnabled": true,
"UseMockCamera": true
"IsShutdownEnabled": false,
"UseMockCamera": false
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "File",
"Args": {
"path": "Logs/cambooth-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 120,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
]
},
"LoggingSettings": {
"LogLevel": "Information",
"LogDirectory": "Logs",
"RemoteServerUrl": "https://log.grimma-fotobox.de",
"RemoteServerApiKey": "8rjvr0zZmceuFZMYydKU"
},
"LycheeSettings": {
"ApiUrl": "https://cambooth-pics.rblnews.de",
"ApiUrl": "https://gallery.grimma-fotobox.de",
"Username": "itob",
"Password": "VfVyqal&Nv8U&P",
"DefaultAlbumId": "gMM3W-Pk9mr8k57k-WU2Jz8t",
"AutoUploadEnabled": false
"DefaultAlbumId": "ukPbEHxdV_q1BOpIVKah7TUR",
"AutoUploadEnabled": true
},
"ConnectionStrings": {
"DefaultConnection": "Server=myServer;Database=myDB;User Id=myUser;Password=myPassword;"

View File

@ -6,9 +6,19 @@
xmlns:local="clr-namespace:CamBooth.App.Core"
mc:Ignorable="d"
Title="GenericOverlayWindow" Height="350" Width="600">
<Window.Resources>
<ResourceDictionary Source="pack://application:,,,/CamBooth.App;component/Resources/ButtonStyles.xaml" />
</Window.Resources>
<Grid>
<TextBlock Name="TbHeader" FontSize="40" HorizontalAlignment="Center">Hoppla</TextBlock>
<TextBlock Name="TbContent" FontSize="25" HorizontalAlignment="Center" VerticalAlignment="Center">Sorry, da ging was schief!</TextBlock>
<Button Click="ButtonBase_OnClick" Width="300" Height="100" FontSize="25" HorizontalAlignment="Center" VerticalAlignment="Bottom" Content="Schließen"></Button>
<Button Click="ButtonBase_OnClick"
Width="300"
Height="100"
FontSize="25"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Content="Schließen"
Style="{StaticResource PrimaryActionButtonStyle}"></Button>
</Grid>
</Window>

View File

@ -1,13 +1,63 @@
using System.Globalization;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Json;
using Serilog.Sinks.Http;
using Microsoft.Extensions.Configuration;
namespace CamBooth.App.Core.Logging;
public class Logger
/// <summary>
/// Custom HTTP Client für Serilog HTTP Sink mit API-Key Support
/// </summary>
public class SeqHttpClient : IHttpClient
{
private readonly string _logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
private readonly string _errorLogPath;
private readonly HttpClient _httpClient;
private readonly string _apiKey;
public SeqHttpClient(string apiKey = "")
{
_httpClient = new HttpClient();
_apiKey = apiKey;
// Setze API-Key Header, falls vorhanden
if (!string.IsNullOrWhiteSpace(_apiKey))
{
_httpClient.DefaultRequestHeaders.Add("X-Seq-Api-Key", _apiKey);
}
}
public void Configure(IConfiguration configuration)
{
// Konfiguration vom HTTP Sink - nicht nötig für unseren Use-Case
}
public async Task<HttpResponseMessage> PostAsync(string requestUri, Stream contentStream, CancellationToken cancellationToken)
{
using (var content = new StreamContent(contentStream))
{
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
return await _httpClient.PostAsync(requestUri, content, cancellationToken);
}
}
public void Dispose()
{
_httpClient?.Dispose();
}
}
public class Logger : IDisposable
{
private readonly Serilog.Core.Logger _serilogLogger;
public event LoggingEventHandler? InfoLog;
public event LoggingEventHandler? ErrorLog;
@ -15,68 +65,191 @@ public class Logger
public event LoggingEventHandler? DebugLog;
public delegate void LoggingEventHandler(string text);
public Logger()
public Logger(IConfiguration configuration)
{
// Logs-Ordner erstellen, falls nicht vorhanden
if (!Directory.Exists(_logsDirectory))
var logLevel = configuration["LoggingSettings:LogLevel"] ?? "Information";
var logDirectoryInput = configuration["LoggingSettings:LogDirectory"] ?? "Logs";
var remoteServerUrl = configuration["LoggingSettings:RemoteServerUrl"];
var remoteServerApiKey = configuration["LoggingSettings:RemoteServerApiKey"];
// Konvertiere zu absolutem Pfad
var logDirectory = Path.IsPathRooted(logDirectoryInput)
? logDirectoryInput
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, logDirectoryInput);
// Erstelle Verzeichnis, falls nicht vorhanden
if (!Directory.Exists(logDirectory))
{
Directory.CreateDirectory(_logsDirectory);
Directory.CreateDirectory(logDirectory);
}
_errorLogPath = Path.Combine(_logsDirectory, "error.txt");
var minimumLevel = ParseLogLevel(logLevel);
var loggerConfig = new LoggerConfiguration()
.MinimumLevel.Is(minimumLevel)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "CamBooth")
.WriteTo.Console(
outputTemplate: "{Timestamp:dd.MM.yyyy HH:mm:ss} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
Path.Combine(logDirectory, "cambooth-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}");
if (!string.IsNullOrWhiteSpace(remoteServerUrl))
{
try
{
loggerConfig.WriteTo.Seq(
serverUrl: remoteServerUrl,
apiKey: remoteServerApiKey,
restrictedToMinimumLevel: minimumLevel
);
Console.WriteLine($"Seq Sink konfiguriert: {remoteServerUrl}");
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Konfigurieren des Seq-Sinks: {ex.Message}");
}
}
_serilogLogger = loggerConfig.CreateLogger();
Log.Logger = _serilogLogger;
Info($"Serilog Logger initialisiert - Level: {logLevel}, Directory: {logDirectory}");
if (!string.IsNullOrWhiteSpace(remoteServerUrl))
{
Info($"Remote Server konfiguriert: {remoteServerUrl}");
}
}
private void WriteToErrorLog(string message)
private LogEventLevel ParseLogLevel(string level)
{
try
return level.ToLowerInvariant() switch
{
File.AppendAllText(_errorLogPath, message + Environment.NewLine);
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Schreiben in Fehlerlog: {ex.Message}");
}
"verbose" => LogEventLevel.Verbose,
"debug" => LogEventLevel.Debug,
"information" => LogEventLevel.Information,
"warning" => LogEventLevel.Warning,
"error" => LogEventLevel.Error,
"fatal" => LogEventLevel.Fatal,
_ => LogEventLevel.Information
};
}
public void Info(string message)
{
Application.Current.Dispatcher.Invoke(() =>
try
{
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [INFO] " + message;
InfoLog?.Invoke(message);
Console.WriteLine(message);
});
if (Application.Current?.Dispatcher != null)
{
Application.Current.Dispatcher.Invoke(() =>
{
var formattedMessage = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [INFO] " + message;
InfoLog?.Invoke(formattedMessage);
});
}
_serilogLogger.Information(message);
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Info-Logging: {ex.Message}");
}
}
public void Warning(string message)
{
Application.Current.Dispatcher.Invoke(() =>
try
{
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [WARNING] " + message;
WarningLog?.Invoke(message);
Console.WriteLine(message);
WriteToErrorLog(message);
});
if (Application.Current?.Dispatcher != null)
{
Application.Current.Dispatcher.Invoke(() =>
{
var formattedMessage = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [WARNING] " + message;
WarningLog?.Invoke(formattedMessage);
});
}
_serilogLogger.Warning(message);
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Warning-Logging: {ex.Message}");
}
}
public void Error(string message)
{
Application.Current.Dispatcher.Invoke(() =>
try
{
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [ERROR] " + message;
ErrorLog?.Invoke(message);
Console.WriteLine(message);
WriteToErrorLog(message);
});
if (Application.Current?.Dispatcher != null)
{
Application.Current.Dispatcher.Invoke(() =>
{
var formattedMessage = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [ERROR] " + message;
ErrorLog?.Invoke(formattedMessage);
});
}
_serilogLogger.Error(message);
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Error-Logging: {ex.Message}");
}
}
public void Error(string message, Exception exception)
{
try
{
if (Application.Current?.Dispatcher != null)
{
Application.Current.Dispatcher.Invoke(() =>
{
var formattedMessage = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [ERROR] " + message;
ErrorLog?.Invoke(formattedMessage);
});
}
_serilogLogger.Error(exception, message);
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Error-Logging: {ex.Message}");
}
}
public void Debug(string message)
{
Application.Current.Dispatcher.Invoke(() =>
try
{
message = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [DEBUG] " + message;
DebugLog?.Invoke(message);
Console.WriteLine(message);
});
if (Application.Current?.Dispatcher != null)
{
Application.Current.Dispatcher.Invoke(() =>
{
var formattedMessage = DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture) + ": [DEBUG] " + message;
DebugLog?.Invoke(formattedMessage);
});
}
_serilogLogger.Debug(message);
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Debug-Logging: {ex.Message}");
}
}
public void Dispose()
{
_serilogLogger?.Dispose();
Log.CloseAndFlush();
}
}

View File

@ -22,6 +22,8 @@ public class CameraService : IDisposable
private readonly LycheeUploadService _lycheeUploadService;
private readonly UploadQueueService _uploadQueueService;
private readonly ICanonAPI _APIHandler;
private CameraValue[] AvList;
@ -47,6 +49,7 @@ public class CameraService : IDisposable
AppSettingsService appSettings,
PictureGalleryService pictureGalleryService,
LycheeUploadService lycheeUploadService,
UploadQueueService uploadQueueService,
ICamera mainCamera,
ICanonAPI APIHandler)
{
@ -54,6 +57,7 @@ public class CameraService : IDisposable
this._appSettings = appSettings;
this._pictureGalleryService = pictureGalleryService;
this._lycheeUploadService = lycheeUploadService;
this._uploadQueueService = uploadQueueService;
this._mainCamera = mainCamera;
this._APIHandler = APIHandler;
try
@ -89,21 +93,43 @@ public class CameraService : IDisposable
try
{
this.RefreshCamera();
List<ICamera> cameraList = this._APIHandler.GetCameraList();
if (cameraList.Any())
// Retry logic for camera detection (some systems need time to initialize)
int maxRetries = 3;
int retryDelay = 500; // milliseconds
for (int attempt = 0; attempt < maxRetries; attempt++)
{
this.OpenSession();
this.SetSettingSaveToComputer();
this.StarLiveView();
}
else
{
this.ReportError("No cameras / devices found");
return;
if (this.CamList != null && this.CamList.Any())
{
break;
}
if (attempt < maxRetries - 1)
{
this._logger.Info($"No cameras found on attempt {attempt + 1}/{maxRetries}, retrying in {retryDelay}ms...");
System.Threading.Thread.Sleep(retryDelay);
this.RefreshCamera();
}
}
string cameraDeviceNames = string.Join(", ", cameraList.Select(cam => cam.DeviceName));
if (this.CamList == null || !this.CamList.Any())
{
this.ReportError("No cameras / devices found");
throw new InvalidOperationException("No cameras / devices found after multiple attempts");
}
this._logger.Info($"Found {this.CamList.Count} camera(s)");
string cameraDeviceNames = string.Join(", ", this.CamList.Select(cam => cam.DeviceName));
this._logger.Info(cameraDeviceNames);
// Update _mainCamera reference to the freshly detected camera
this._mainCamera = this.CamList[0];
this._logger.Info($"Selected camera: {this._mainCamera.DeviceName}");
this.OpenSession();
this.SetSettingSaveToComputer();
this.StarLiveView();
}
catch (Exception ex)
{
@ -161,13 +187,11 @@ public class CameraService : IDisposable
{
try
{
if (this.CamList == null || this.CamList.Count == 0)
if (this._mainCamera == null)
{
throw new InvalidOperationException("No cameras available in camera list");
throw new InvalidOperationException("Camera reference is null. Make sure ConnectCamera is called first.");
}
this._mainCamera = this.CamList[0];
// Check if session is already open
if (this._mainCamera.SessionOpen)
{
@ -377,34 +401,9 @@ public class CameraService : IDisposable
this._pictureGalleryService.LoadThumbnailsToCache();
});
// Auto-Upload zu Lychee, falls aktiviert
if (this._appSettings.LycheeAutoUploadEnabled)
{
this._logger.Info("Auto-Upload aktiviert. Starte Upload zu Lychee...");
// Upload im Hintergrund, damit die Fotobox nicht blockiert wird
_ = Task.Run(async () =>
{
try
{
var albumId = this._appSettings.LycheeDefaultAlbumId;
var uploadSuccess = await this._lycheeUploadService.UploadImageAsync(savedPhotoPath, albumId);
if (uploadSuccess)
{
this._logger.Info($"✅ Lychee-Upload erfolgreich: {Info.FileName}");
}
else
{
this._logger.Warning($"⚠️ Lychee-Upload fehlgeschlagen: {Info.FileName}");
}
}
catch (Exception ex)
{
this._logger.Error($"❌ Fehler beim Lychee-Upload: {ex.Message}");
}
});
}
// Füge neues Foto zur Upload-Queue hinzu (wenn Auto-Upload aktiviert)
this._uploadQueueService.QueueNewPhoto(savedPhotoPath);
this._logger.Info($"Foto zur Upload-Queue hinzugefügt: {Info.FileName}");
}
catch (Exception ex)
{

View File

@ -75,11 +75,9 @@
x:Name="ConnectButton"
Content="Verbinden"
Click="ConnectButton_Click"
Style="{StaticResource PrimaryActionButtonStyle}"
Width="140"
Height="45"
Background="#D4AF37"
Foreground="#1F1A00"
FontWeight="SemiBold" />
Height="45" />
</Grid>
</Border>
@ -165,18 +163,16 @@
<ui:Button x:Name="UploadLastPhotoButton"
Content="Letztes Foto hochladen"
Click="UploadLastPhotoButton_Click"
Style="{StaticResource SecondaryButtonStyle}"
Width="200"
Height="45"
Margin="0,0,10,0"
Appearance="Secondary" />
Margin="0,0,10,0" />
<ui:Button x:Name="UploadAllPhotosButton"
Content="Alle Fotos hochladen"
Click="UploadAllPhotosButton_Click"
Style="{StaticResource PrimaryActionButtonStyle}"
Width="200"
Height="45"
Background="#D4AF37"
Foreground="#1F1A00"
FontWeight="SemiBold" />
Height="45" />
</StackPanel>
</Grid>
</Border>

View File

@ -1,10 +1,11 @@
using System.IO;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Windows.Media.Imaging;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
@ -18,6 +19,7 @@ public class LycheeUploadService : IDisposable
private readonly AppSettingsService _appSettings;
private readonly Logger _logger;
private readonly HttpClient _httpClient;
private readonly QRCodeGenerationService _qrCodeGenerationService;
private readonly CookieContainer _cookieContainer;
private readonly HttpClientHandler _httpClientHandler;
private string? _csrfToken;
@ -27,6 +29,7 @@ public class LycheeUploadService : IDisposable
{
_appSettings = appSettings;
_logger = logger;
_qrCodeGenerationService = new QRCodeGenerationService(logger);
// CookieContainer für Session-Management
_cookieContainer = new CookieContainer();
@ -47,6 +50,11 @@ public class LycheeUploadService : IDisposable
/// </summary>
public bool IsAuthenticated => _isAuthenticated;
/// <summary>
/// Der zuletzt generierte QR-Code
/// </summary>
public BitmapImage? LastGeneratedQRCode { get; private set; }
/// <summary>
/// Authentifiziert sich bei Lychee
/// </summary>
@ -532,6 +540,52 @@ public class LycheeUploadService : IDisposable
}
}
/// <summary>
/// Generiert einen QR-Code für die Lychee-Galerie-URL
/// </summary>
/// <returns>BitmapImage des QR-Codes oder null bei Fehler</returns>
public BitmapImage? GenerateGalleryQRCode()
{
try
{
var lycheeUrl = _appSettings.LycheeApiUrl;
var albumId = _appSettings.LycheeDefaultAlbumId;
if (string.IsNullOrEmpty(lycheeUrl))
{
_logger.Error("Lychee-URL ist nicht konfiguriert. Kann QR-Code nicht generieren.");
return null;
}
if (string.IsNullOrEmpty(albumId))
{
_logger.Warning("Lychee DefaultAlbumId ist nicht konfiguriert. QR-Code zeigt nur die Basis-URL.");
}
_logger.Info("Generiere QR-Code für Lychee-Galerie...");
// Konstruiere die Gallery-URL: ApiUrl + /Gallery + DefaultAlbumId
var galleryUrl = $"{lycheeUrl}/gallery/{albumId}";
_logger.Debug($"QR-Code URL: {galleryUrl}");
// Generiere QR-Code mit der Gallery-URL
var qrCode = _qrCodeGenerationService.GenerateQRCode(galleryUrl);
if (qrCode != null)
{
LastGeneratedQRCode = qrCode;
_logger.Info("✅ QR-Code erfolgreich generiert");
}
return qrCode;
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Generieren des QR-Codes: {ex.Message}");
return null;
}
}
public void Dispose()
{
_httpClient?.Dispose();

View File

@ -0,0 +1,91 @@
<Window x:Class="CamBooth.App.Features.LycheeUpload.QRCodeDisplayWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
mc:Ignorable="d"
Title="QR Code - Zum Fotoalbum"
Width="900" Height="1000"
Background="Black"
WindowStyle="None"
ResizeMode="NoResize"
AllowsTransparency="True"
WindowStartupLocation="CenterScreen"
Foreground="White">
<Window.Resources>
<ResourceDictionary Source="pack://application:,,,/CamBooth.App;component/Resources/ButtonStyles.xaml" />
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Close Button (top-right) -->
<Button Grid.Row="0"
Content="✕"
Style="{StaticResource SecondaryButtonStyle}"
Width="60"
Height="60"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="10"
FontSize="24"
Click="CloseButton_Click" />
<!-- Main Content -->
<StackPanel Grid.Row="0" Grid.RowSpan="3"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="40">
<!-- Top Text: "Scan Me" -->
<TextBlock Text="SCAN ME"
Foreground="White"
FontSize="56"
FontWeight="Bold"
TextAlignment="Center"
Margin="0 0 0 30" />
<!-- QR Code Image -->
<Border Background="White"
Padding="20"
CornerRadius="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0 0 0 30">
<Image x:Name="QRCodeImage"
Width="500"
Height="500"
RenderOptions.BitmapScalingMode="HighQuality"
Margin="0" />
</Border>
<!-- Bottom Text: "zum Fotoalbum" -->
<TextBlock Text="zum Fotoalbum"
Foreground="White"
FontSize="48"
FontWeight="Bold"
TextAlignment="Center"
Margin="0 30 0 0" />
</StackPanel>
<!-- Close Button at the bottom -->
<Button Grid.Row="3"
Content="Fenster schließen"
Style="{StaticResource PrimaryActionButtonStyle}"
Width="300"
Height="60"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0 0 0 20"
FontSize="18"
FontWeight="Bold"
Click="CloseButton_Click" />
</Grid>
</Window>

View File

@ -0,0 +1,28 @@
using System.Windows;
using System.Windows.Media.Imaging;
namespace CamBooth.App.Features.LycheeUpload;
public partial class QRCodeDisplayWindow : Window
{
public QRCodeDisplayWindow()
{
InitializeComponent();
}
/// <summary>
/// Setzt den QR-Code für die Anzeige
/// </summary>
public void SetQRCode(BitmapImage qrCodeImage)
{
if (qrCodeImage != null)
{
QRCodeImage.Source = qrCodeImage;
}
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
this.Close();
}
}

View File

@ -0,0 +1,108 @@
using System.Drawing;
using System.IO;
using System.Windows.Media.Imaging;
using CamBooth.App.Core.Logging;
using QRCoder;
namespace CamBooth.App.Features.LycheeUpload;
/// <summary>
/// Service für die Generierung von QR-Codes als BitmapImage
/// </summary>
public class QRCodeGenerationService
{
private readonly Logger _logger;
public QRCodeGenerationService(Logger logger)
{
_logger = logger;
}
/// <summary>
/// Generiert einen QR-Code basierend auf einem URL-String
/// </summary>
/// <param name="qrData">Die Daten für den QR-Code (z.B. URL)</param>
/// <param name="pixelsPerModule">Größe in Pixeln pro QR-Code-Modul</param>
/// <returns>BitmapImage des QR-Codes oder null bei Fehler</returns>
public BitmapImage? GenerateQRCode(string qrData, int pixelsPerModule = 20)
{
try
{
_logger.Debug($"Generiere QR-Code für: {qrData}");
using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
{
QRCodeData qrCodeData = qrGenerator.CreateQrCode(qrData, QRCodeGenerator.ECCLevel.Q);
using (QRCode qrCode = new QRCode(qrCodeData))
{
Bitmap qrCodeImage = qrCode.GetGraphic(pixelsPerModule, System.Drawing.Color.Black, System.Drawing.Color.White, true);
// Konvertiere Bitmap zu BitmapImage
BitmapImage bitmapImage = ConvertBitmapToBitmapImage(qrCodeImage);
_logger.Debug("✅ QR-Code erfolgreich generiert");
return bitmapImage;
}
}
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Generieren des QR-Codes: {ex.Message}");
return null;
}
}
/// <summary>
/// Konvertiert ein System.Drawing.Bitmap zu einem WPF BitmapImage
/// </summary>
private BitmapImage ConvertBitmapToBitmapImage(Bitmap bitmap)
{
using (var stream = new MemoryStream())
{
bitmap.Save(stream, System.Drawing.Imaging.ImageFormat.Png);
stream.Seek(0, SeekOrigin.Begin);
var bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.StreamSource = stream;
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
bitmapImage.EndInit();
bitmapImage.Freeze();
return bitmapImage;
}
}
/// <summary>
/// Speichert einen QR-Code als Datei
/// </summary>
/// <param name="qrData">Die Daten für den QR-Code</param>
/// <param name="filePath">Zielpath für die QR-Code-Datei</param>
/// <param name="pixelsPerModule">Größe in Pixeln pro QR-Code-Modul</param>
/// <returns>True wenn erfolgreich, sonst False</returns>
public bool SaveQRCodeToFile(string qrData, string filePath, int pixelsPerModule = 20)
{
try
{
_logger.Debug($"Speichere QR-Code unter: {filePath}");
using (QRCodeGenerator qrGenerator = new QRCodeGenerator())
{
QRCodeData qrCodeData = qrGenerator.CreateQrCode(qrData, QRCodeGenerator.ECCLevel.Q);
using (QRCode qrCode = new QRCode(qrCodeData))
{
Bitmap qrCodeImage = qrCode.GetGraphic(pixelsPerModule, System.Drawing.Color.Black, System.Drawing.Color.White, true);
qrCodeImage.Save(filePath, System.Drawing.Imaging.ImageFormat.Png);
_logger.Debug("✅ QR-Code erfolgreich gespeichert");
return true;
}
}
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Speichern des QR-Codes: {ex.Message}");
return false;
}
}
}

View File

@ -0,0 +1,288 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
namespace CamBooth.App.Features.LycheeUpload;
/// <summary>
/// Verwaltet die Upload-Queue und führt Uploads im Hintergrund durch
/// </summary>
public class UploadQueueService : IDisposable
{
private readonly Logger _logger;
private readonly AppSettingsService _appSettings;
private readonly LycheeUploadService _lycheeUploadService;
private readonly UploadTracker _uploadTracker;
private readonly Queue<string> _uploadQueue;
private Task? _uploadTask;
private CancellationTokenSource? _cancellationTokenSource;
private bool _isRunning = false;
private readonly object _queueLock = new object();
// Konfigurierbare Parameter
private const int UploadRetryDelayMs = 5000; // 5 Sekunden Wartezeit zwischen Retries
private const int MaxRetries = 3;
private const int CheckIntervalMs = 2000; // Überprüfe Queue alle 2 Sekunden
public UploadQueueService(Logger logger, AppSettingsService appSettings, LycheeUploadService lycheeUploadService)
{
_logger = logger;
_appSettings = appSettings;
_lycheeUploadService = lycheeUploadService;
_uploadTracker = new UploadTracker(logger, "UploadTracking");
_uploadQueue = new Queue<string>();
}
/// <summary>
/// Startet den Upload-Service
/// </summary>
public void Start()
{
if (_isRunning)
{
_logger.Warning("Upload-Service läuft bereits");
return;
}
_isRunning = true;
_cancellationTokenSource = new CancellationTokenSource();
_uploadTask = ProcessQueueAsync(_cancellationTokenSource.Token);
_logger.Info("Upload-Service gestartet");
}
/// <summary>
/// Stoppt den Upload-Service
/// </summary>
public async Task StopAsync()
{
if (!_isRunning)
{
return;
}
_isRunning = false;
_cancellationTokenSource?.Cancel();
if (_uploadTask != null)
{
try
{
await _uploadTask;
}
catch (OperationCanceledException)
{
// Erwartet
}
}
_logger.Info("Upload-Service gestoppt");
}
/// <summary>
/// Scannt das Bild-Verzeichnis und fügt fehlgeschlagene Uploads zur Queue hinzu
/// </summary>
public void ScanAndQueueFailedUploads()
{
try
{
if (!_appSettings.LycheeAutoUploadEnabled)
{
_logger.Debug("Lychee Auto-Upload ist deaktiviert");
return;
}
var failedFiles = _uploadTracker.GetFailedUploads(_appSettings.PictureLocation);
lock (_queueLock)
{
foreach (var filePath in failedFiles)
{
if (!_uploadQueue.Contains(filePath))
{
_uploadQueue.Enqueue(filePath);
_logger.Debug($"Fehlgeschlagenes Upload in Queue hinzugefügt: {Path.GetFileName(filePath)}");
}
}
}
if (failedFiles.Count > 0)
{
_logger.Info($"{failedFiles.Count} fehlgeschlagene Uploads in Queue hinzugefügt");
}
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Scannen fehlgeschlagener Uploads: {ex.Message}");
}
}
/// <summary>
/// Fügt ein neu erstelltes Foto zur Upload-Queue hinzu
/// </summary>
public void QueueNewPhoto(string photoPath)
{
if (!_appSettings.LycheeAutoUploadEnabled)
{
_logger.Debug("Lychee Auto-Upload ist deaktiviert");
return;
}
lock (_queueLock)
{
if (!_uploadQueue.Contains(photoPath))
{
_uploadQueue.Enqueue(photoPath);
_logger.Info($"Neues Foto in Upload-Queue hinzugefügt: {Path.GetFileName(photoPath)}");
}
}
}
/// <summary>
/// Gibt die aktuelle Größe der Upload-Queue zurück
/// </summary>
public int GetQueueSize()
{
lock (_queueLock)
{
return _uploadQueue.Count;
}
}
/// <summary>
/// Hauptschleife für die Verarbeitung der Upload-Queue
/// </summary>
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
{
_logger.Info("Upload-Queue-Verarbeitung gestartet");
try
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
string? filePath = null;
lock (_queueLock)
{
if (_uploadQueue.Count > 0)
{
filePath = _uploadQueue.Dequeue();
}
}
if (filePath != null)
{
await ProcessUploadAsync(filePath, cancellationToken);
}
else
{
// Kurz warten, wenn Queue leer
await Task.Delay(CheckIntervalMs, cancellationToken);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.Error($"Fehler in Upload-Queue-Verarbeitung: {ex.Message}");
await Task.Delay(UploadRetryDelayMs, cancellationToken);
}
}
}
catch (OperationCanceledException)
{
_logger.Info("Upload-Queue-Verarbeitung wurde abgebrochen");
}
}
/// <summary>
/// Verarbeitet den Upload einer einzelnen Datei mit Retry-Logik
/// </summary>
private async Task ProcessUploadAsync(string filePath, CancellationToken cancellationToken)
{
var fileName = Path.GetFileName(filePath);
try
{
// Prüfe, ob Datei noch existiert
if (!File.Exists(filePath))
{
_logger.Warning($"Bild nicht gefunden, überspringe: {fileName}");
_uploadTracker.MarkAsFailedUpload(fileName, "Datei nicht gefunden");
return;
}
// Prüfe, ob bereits hochgeladen
if (_uploadTracker.IsUploaded(filePath))
{
_logger.Debug($"Bild bereits hochgeladen: {fileName}");
return;
}
// Versuche Upload mit Retry
bool uploadSuccess = false;
string? lastError = null;
for (int attempt = 1; attempt <= MaxRetries; attempt++)
{
try
{
_logger.Info($"Upload-Versuch {attempt}/{MaxRetries} für {fileName}...");
var albumId = _appSettings.LycheeDefaultAlbumId;
uploadSuccess = await _lycheeUploadService.UploadImageAsync(filePath, albumId);
if (uploadSuccess)
{
_uploadTracker.MarkAsUploaded(filePath);
_logger.Info($"✅ Upload erfolgreich: {fileName}");
return;
}
else
{
lastError = "Upload-Fehler vom Service";
_logger.Warning($"Upload fehlgeschlagen (Versuch {attempt}): {fileName}");
}
}
catch (Exception ex)
{
lastError = ex.Message;
_logger.Error($"Fehler bei Upload-Versuch {attempt}: {ex.Message}");
}
// Warte vor nächstem Versuch (nicht nach dem letzten)
if (attempt < MaxRetries)
{
await Task.Delay(UploadRetryDelayMs, cancellationToken);
}
}
// Alle Versuche fehlgeschlagen
_uploadTracker.MarkAsFailedUpload(filePath, lastError ?? "Unbekannter Fehler");
_logger.Error($"❌ Upload fehlgeschlagen nach {MaxRetries} Versuchen: {fileName}");
// Füge wieder zur Queue hinzu für späteren Retry (längerer Abstand)
_logger.Info($"Bild wird später erneut versucht: {fileName}");
}
catch (Exception ex)
{
_logger.Error($"Kritischer Fehler bei Upload-Verarbeitung von {fileName}: {ex.Message}");
_uploadTracker.MarkAsFailedUpload(filePath, ex.Message);
}
}
public void Dispose()
{
StopAsync().Wait(TimeSpan.FromSeconds(5));
_cancellationTokenSource?.Dispose();
_lycheeUploadService?.Dispose();
}
}

View File

@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using CamBooth.App.Core.Logging;
namespace CamBooth.App.Features.LycheeUpload;
/// <summary>
/// Verfolgt welche Bilder bereits hochgeladen wurden
/// </summary>
public class UploadTracker
{
private readonly Logger _logger;
private readonly string _trackerFilePath;
private Dictionary<string, UploadedImageInfo> _uploadedImages;
public class UploadedImageInfo
{
public string FileName { get; set; }
public DateTime UploadedAt { get; set; }
public string? LycheeId { get; set; }
public int RetryCount { get; set; }
public DateTime? LastRetryAt { get; set; }
public string? ErrorMessage { get; set; }
}
public UploadTracker(Logger logger, string trackerDirectory = "")
{
_logger = logger;
// Verwende absoluten Pfad
if (string.IsNullOrWhiteSpace(trackerDirectory))
{
trackerDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UploadTracking");
}
else if (!Path.IsPathRooted(trackerDirectory))
{
trackerDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, trackerDirectory);
}
if (!Directory.Exists(trackerDirectory))
{
Directory.CreateDirectory(trackerDirectory);
}
_trackerFilePath = Path.Combine(trackerDirectory, "upload_history.json");
_uploadedImages = new Dictionary<string, UploadedImageInfo>();
LoadFromDisk();
}
/// <summary>
/// Lädt den Upload-History aus der Datei
/// </summary>
private void LoadFromDisk()
{
try
{
if (File.Exists(_trackerFilePath))
{
var json = File.ReadAllText(_trackerFilePath);
var data = JsonSerializer.Deserialize<Dictionary<string, UploadedImageInfo>>(json);
_uploadedImages = data ?? new Dictionary<string, UploadedImageInfo>();
_logger.Info($"Upload-History geladen: {_uploadedImages.Count} Einträge");
}
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Laden der Upload-History: {ex.Message}");
_uploadedImages = new Dictionary<string, UploadedImageInfo>();
}
}
/// <summary>
/// Speichert den Upload-History auf die Festplatte
/// </summary>
private void SaveToDisk()
{
try
{
var json = JsonSerializer.Serialize(_uploadedImages, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_trackerFilePath, json);
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Speichern der Upload-History: {ex.Message}");
}
}
/// <summary>
/// Markiert ein Bild als erfolgreich hochgeladen
/// </summary>
public void MarkAsUploaded(string fileName, string? lycheeId = null)
{
var fileNameOnly = Path.GetFileName(fileName);
_uploadedImages[fileNameOnly] = new UploadedImageInfo
{
FileName = fileNameOnly,
UploadedAt = DateTime.UtcNow,
LycheeId = lycheeId,
RetryCount = 0,
LastRetryAt = null,
ErrorMessage = null
};
SaveToDisk();
_logger.Info($"Bild als hochgeladen markiert: {fileNameOnly}");
}
/// <summary>
/// Markiert einen Upload-Fehler für ein Bild
/// </summary>
public void MarkAsFailedUpload(string fileName, string? errorMessage = null)
{
var fileNameOnly = Path.GetFileName(fileName);
if (_uploadedImages.ContainsKey(fileNameOnly))
{
var info = _uploadedImages[fileNameOnly];
info.RetryCount++;
info.LastRetryAt = DateTime.UtcNow;
info.ErrorMessage = errorMessage;
}
else
{
_uploadedImages[fileNameOnly] = new UploadedImageInfo
{
FileName = fileNameOnly,
UploadedAt = DateTime.UtcNow,
RetryCount = 1,
LastRetryAt = DateTime.UtcNow,
ErrorMessage = errorMessage
};
}
SaveToDisk();
_logger.Warning($"Upload-Fehler für {fileNameOnly}: {errorMessage}");
}
/// <summary>
/// Prüft, ob ein Bild bereits hochgeladen wurde
/// </summary>
public bool IsUploaded(string fileName)
{
var fileNameOnly = Path.GetFileName(fileName);
return _uploadedImages.ContainsKey(fileNameOnly) && _uploadedImages[fileNameOnly].UploadedAt != default;
}
/// <summary>
/// Gibt alle Bilder zurück, die noch nicht hochgeladen wurden
/// </summary>
public List<string> GetFailedUploads(string pictureDirectory)
{
var failedFiles = new List<string>();
if (!Directory.Exists(pictureDirectory))
{
return failedFiles;
}
try
{
var allFiles = Directory.GetFiles(pictureDirectory, "img_*.jpg");
foreach (var filePath in allFiles)
{
var fileName = Path.GetFileName(filePath);
// Wenn nicht im Tracker oder mit Fehler → zu Retry-Liste hinzufügen
if (!_uploadedImages.ContainsKey(fileName) ||
_uploadedImages[fileName].UploadedAt == default)
{
failedFiles.Add(filePath);
}
}
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Scannen von {pictureDirectory}: {ex.Message}");
}
return failedFiles;
}
/// <summary>
/// Gibt die Anzahl fehlgeschlagener Uploads zurück
/// </summary>
public int GetFailedUploadCount()
{
return _uploadedImages.Count(x => x.Value.UploadedAt == default);
}
}

View File

@ -14,6 +14,19 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Close Button (top-right) -->
<Button Grid.Row="0"
Content="✕"
Style="{StaticResource SecondaryButtonStyle}"
Width="60"
Height="60"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="20"
FontSize="24"
Click="CloseGallery_Click"
Panel.ZIndex="10" />
<ScrollViewer Grid.Row="0" x:Name="GalleryScrollViewer" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Background="Black" CanContentScroll="False">
<ItemsControl x:Name="PicturesPanel" Background="Black">
<ItemsControl.ItemsPanel>
@ -36,15 +49,17 @@
</Grid.ColumnDefinitions>
<!-- First Page Button -->
<Button Grid.Column="0" x:Name="FirstPageButton" Content="⏮️"
Width="60" Height="50" Margin="5,0"
FontSize="20" Background="#3C3C3C" Foreground="White"
<Button Grid.Column="0"
x:Name="FirstPageButton"
Content="⏮️"
Style="{StaticResource PagingButtonStyle}"
Click="FirstPageButton_Click" />
<!-- Previous Page Button -->
<Button Grid.Column="1" x:Name="PreviousPageButton" Content="◀️"
Width="60" Height="50" Margin="5,0"
FontSize="20" Background="#3C3C3C" Foreground="White"
<Button Grid.Column="1"
x:Name="PreviousPageButton"
Content="◀️"
Style="{StaticResource PagingButtonStyle}"
Click="PreviousPageButton_Click" />
<!-- Page Info -->
@ -54,15 +69,17 @@
Text="Seite 1 von 1" MinWidth="150" TextAlignment="Center" />
<!-- Next Page Button -->
<Button Grid.Column="3" x:Name="NextPageButton" Content="▶️"
Width="60" Height="50" Margin="5,0"
FontSize="20" Background="#3C3C3C" Foreground="White"
<Button Grid.Column="3"
x:Name="NextPageButton"
Content="▶️"
Style="{StaticResource PagingButtonStyle}"
Click="NextPageButton_Click" />
<!-- Last Page Button -->
<Button Grid.Column="4" x:Name="LastPageButton" Content="⏭️"
Width="60" Height="50" Margin="5,0"
FontSize="20" Background="#3C3C3C" Foreground="White"
<Button Grid.Column="4"
x:Name="LastPageButton"
Content="⏭️"
Style="{StaticResource PagingButtonStyle}"
Click="LastPageButton_Click" />
</Grid>
</Border>

View File

@ -1,4 +1,4 @@
using System.Windows;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
@ -7,6 +7,7 @@ using System.Windows.Media.Imaging;
using CamBooth.App.Core.AppSettings;
using CamBooth.App.Core.Logging;
using CamBooth.App.Features.LycheeUpload;
using Wpf.Ui.Controls;
@ -24,18 +25,24 @@ public partial class PictureGalleryPage : Page
private readonly PictureGalleryService _pictureGalleryService;
private readonly LycheeUploadService _lycheeUploadService;
private ContentDialog? _openContentDialog;
private int _currentPage = 1;
private int _itemsPerPage = 12;
private int _itemsPerPage = 11;
private int _totalPages = 1;
// QR Code wird nur einmal erstellt und dann wiederverwendet
private Border? _qrCodeBorder = null;
public PictureGalleryPage(AppSettingsService appSettingsService, Logger logger, PictureGalleryService pictureGalleryService)
public PictureGalleryPage(AppSettingsService appSettingsService, Logger logger, PictureGalleryService pictureGalleryService, LycheeUploadService lycheeUploadService)
{
this._appSettingsService = appSettingsService;
this._logger = logger;
this._pictureGalleryService = pictureGalleryService;
this._lycheeUploadService = lycheeUploadService;
this.InitializeComponent();
this.Initialize();
}
@ -45,6 +52,9 @@ public partial class PictureGalleryPage : Page
{
try
{
// QR-Code nur einmal beim Initialisieren erstellen
CreateQRCodeBorder();
_currentPage = 1;
CalculateTotalPages();
LoadCurrentPage();
@ -56,6 +66,56 @@ public partial class PictureGalleryPage : Page
}
}
private void CreateQRCodeBorder()
{
try
{
var qrCodeImage = this._lycheeUploadService.GenerateGalleryQRCode();
if (qrCodeImage != null)
{
// QR-Code Container mit Label
var qrContainer = new StackPanel { Orientation = Orientation.Vertical };
var qrImageControl = new Image
{
Source = qrCodeImage,
Width = 220,
Margin = new Thickness(4)
};
var qrSubLabel = new TextBlock
{
Text = "zum Fotoalbum",
Foreground = new SolidColorBrush(Colors.White),
FontSize = 11,
FontWeight = FontWeights.Bold,
TextAlignment = TextAlignment.Center,
Margin = new Thickness(4, 0, 4, 2)
};
qrContainer.Children.Add(qrSubLabel);
qrContainer.Children.Add(qrImageControl);
_qrCodeBorder = new Border
{
Background = new SolidColorBrush(Colors.Black),
BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#D4AF37")),
BorderThickness = new Thickness(2),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(4),
Child = qrContainer,
Margin = new Thickness(4)
};
this._logger.Debug("✅ QR-Code statisch erstellt");
}
}
catch (Exception ex)
{
this._logger.Warning($"QR-Code konnte nicht generiert werden: {ex.Message}");
}
}
private void ContentDialog_OnButtonClicked(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
@ -74,6 +134,13 @@ public partial class PictureGalleryPage : Page
// Clear existing items
this.PicturesPanel.Items.Clear();
// Füge den statischen QR-Code als erstes Element hinzu
if (_qrCodeBorder != null)
{
this.PicturesPanel.Items.Add(_qrCodeBorder);
}
// Jetzt die regulären Bilder hinzufügen
int totalThumbnails = this._pictureGalleryService.ThumbnailsOrderedByNewestDescending.Count;
int endIndex = Math.Min(startIndex + count, totalThumbnails);
@ -276,6 +343,19 @@ public partial class PictureGalleryPage : Page
UpdatePagerControls();
GalleryScrollViewer.ScrollToTop();
}
private void CloseGallery_Click(object sender, RoutedEventArgs e)
{
// Finde das MainWindow über die Window.GetWindow Methode
var window = Window.GetWindow(this);
if (window is MainWindow mainWindow)
{
mainWindow.ClosePicturePanel();
}
else
{
_logger.Warning("MainWindow konnte nicht gefunden werden, um die Galerie zu schließen");
}
}
}

View File

@ -53,28 +53,245 @@
Margin="24 24 24 12"/>
<liveView:TimerControlRectangleAnimation x:Name="TimerControlRectangleAnimation" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</StackPanel>
<!-- Action Buttons Container (bottom-right) -->
<StackPanel Grid.Row="0"
x:Name="ActionButtonsContainer"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="20"
Panel.ZIndex="3"
Visibility="Hidden"
Orientation="Vertical"
Width="100">
<!-- Gallery/Sammlung Button -->
<Border Background="#1A1A1A"
BorderBrush="#D4AF37"
BorderThickness="2"
CornerRadius="10"
Padding="8"
Cursor="Hand"
Margin="0,0,0,12"
MouseLeftButtonDown="SetVisibilityPicturePanel"
Focusable="False">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="12" ShadowDepth="4" Opacity="0.5"/>
</Border.Effect>
<Border.Triggers>
<EventTrigger RoutedEvent="MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleX)"
To="1.05" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleY)"
To="1.05" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleX)"
To="1.0" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleY)"
To="1.0" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Border.Triggers>
<Border.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" CenterX="0.5" CenterY="0.5"/>
</Border.RenderTransform>
<Border.RenderTransformOrigin>
<Point X="0.5" Y="0.5"/>
</Border.RenderTransformOrigin>
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<!-- Gallery Icon -->
<Grid Width="55" Height="55" Margin="0,0,0,6">
<Border BorderBrush="#D4AF37"
BorderThickness="3"
CornerRadius="3"
Padding="4">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Row="0" Grid.Column="0"
Background="#D4AF37"
Margin="1.5"
CornerRadius="1.5"/>
<Border Grid.Row="0" Grid.Column="1"
Background="#D4AF37"
Margin="1.5"
CornerRadius="1.5"/>
<Border Grid.Row="1" Grid.Column="0"
Background="#D4AF37"
Margin="1.5"
CornerRadius="1.5"/>
<Border Grid.Row="1" Grid.Column="1"
Background="#D4AF37"
Margin="1.5"
CornerRadius="1.5"/>
</Grid>
</Border>
</Grid>
<!-- Text -->
<TextBlock Text="Foto"
Foreground="White"
FontSize="11"
FontWeight="Bold"
TextAlignment="Center"
Margin="0,0,0,2"/>
<TextBlock Text="Galerie"
Foreground="#D4AF37"
FontSize="8"
FontWeight="SemiBold"
TextAlignment="Center"/>
</StackPanel>
</Border>
<!-- Badge for new photos -->
<Border x:Name="NewPhotosBadge"
Background="#E61A1A1A"
BorderBrush="#FF0000"
BorderThickness="2"
CornerRadius="12"
Width="30"
Height="30"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,-42,-10,0"
Visibility="Collapsed"
Panel.ZIndex="10">
<TextBlock x:Name="NewPhotoCountText"
Text="1"
Foreground="White"
FontSize="16"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<!-- QR/Sharing Button -->
<Border Background="#1A1A1A"
BorderBrush="#D4AF37"
BorderThickness="2"
CornerRadius="10"
Padding="8"
Cursor="Hand"
MouseLeftButtonDown="ShowQRCode"
Focusable="False">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="12" ShadowDepth="4" Opacity="0.5"/>
</Border.Effect>
<Border.Triggers>
<EventTrigger RoutedEvent="MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleX)"
To="1.05" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleY)"
To="1.05" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleX)"
To="1.0" Duration="0:0:0.2"/>
<DoubleAnimation Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleY)"
To="1.0" Duration="0:0:0.2"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Border.Triggers>
<Border.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" CenterX="0.5" CenterY="0.5"/>
</Border.RenderTransform>
<Border.RenderTransformOrigin>
<Point X="0.5" Y="0.5"/>
</Border.RenderTransformOrigin>
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<!-- QR Code Icon -->
<Grid Width="55" Height="55" Margin="0,0,0,6">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Row="0" Grid.Column="0" Background="#D4AF37" Margin="1.5" CornerRadius="1.5"/>
<Border Grid.Row="0" Grid.Column="1" Background="Transparent" Margin="1.5"/>
<Border Grid.Row="0" Grid.Column="2" Background="#D4AF37" Margin="1.5" CornerRadius="1.5"/>
<Border Grid.Row="1" Grid.Column="0" Background="Transparent" Margin="1.5"/>
<Border Grid.Row="1" Grid.Column="1" Background="#D4AF37" Margin="1.5" CornerRadius="1.5"/>
<Border Grid.Row="1" Grid.Column="2" Background="Transparent" Margin="1.5"/>
<Border Grid.Row="2" Grid.Column="0" Background="#D4AF37" Margin="1.5" CornerRadius="1.5"/>
<Border Grid.Row="2" Grid.Column="1" Background="Transparent" Margin="1.5"/>
<Border Grid.Row="2" Grid.Column="2" Background="#D4AF37" Margin="1.5" CornerRadius="1.5"/>
<Border Grid.RowSpan="3" Grid.ColumnSpan="3"
BorderBrush="#D4AF37"
BorderThickness="2"
CornerRadius="3"/>
</Grid>
<!-- Text -->
<TextBlock Text="Herunterladen"
Foreground="White"
FontSize="10"
FontWeight="Bold"
TextAlignment="Center"
Margin="0,0,0,2"/>
<TextBlock Text="Fotos"
Foreground="#D4AF37"
FontSize="8"
FontWeight="SemiBold"
TextAlignment="Center"/>
</StackPanel>
</Border>
</StackPanel>
<!-- Hide Debug Button (top-right) -->
<ui:Button Grid.Row="0"
x:Name="HideDebugButton"
Content="Hide Debug"
Click="SetVisibilityDebugConsole"
Width="160"
Height="60"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="20"
Panel.ZIndex="5"
Visibility="Hidden"
FontSize="16"
Appearance="Secondary" />
<Button Grid.Row="0"
x:Name="HideDebugButton"
Content="Hide Debug"
Click="SetVisibilityDebugConsole"
Style="{StaticResource SecondaryButtonStyle}"
Width="160"
Height="60"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="20"
Panel.ZIndex="5"
Visibility="Hidden"
FontSize="16" />
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Bottom" Name="ButtonPanel" Background="Transparent" Panel.ZIndex="2"
Visibility="Hidden"
Margin="0 0 0 0">
<!-- <ui:Button Content="Take Photo" Click="StartTakePhotoProcess" Width="200" Height="75" VerticalAlignment="Bottom" -->
<!-- Margin="0 0 5 0" /> -->
<Button Width="160" Height="160"
Margin="0 0 0 30">
<!-- Take Photo Button -->
<Button Width="240" Height="120"
Click="StartTakePhotoProcess"
Style="{StaticResource ModernRounded3DButtonStyle}"
FontSize="64"
Foreground="White"
Margin="0 0 5 0"
@ -82,57 +299,25 @@
BorderBrush="Transparent"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Style="{StaticResource ModernRounded3DButtonStyle}"/>
<ui:Button x:Name="DebugCloseButton" Content="Close" Appearance="Danger" Click="CloseApp" Width="200" Height="75" VerticalAlignment="Bottom" Margin="0 0 5 0" />
Content="🔴"/>
<Button x:Name="DebugCloseButton"
Content="Close"
Style="{StaticResource SecondaryButtonStyle}"
Click="CloseApp"
Width="200"
Height="75"
VerticalAlignment="Bottom"
Margin="0 0 5 0"
FontSize="16"
FontWeight="Bold" />
</StackPanel>
<!-- Picture Gallery Dock (bottom-right) -->
<Grid Grid.Row="0"
x:Name="PictureGalleryDock"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="20"
Panel.ZIndex="3"
Visibility="Hidden">
<ui:Button Content="&#xE7C3;"
FontFamily="Segoe MDL2 Assets"
FontSize="30"
Width="72"
Height="72"
Click="SetVisibilityPicturePanel"
Background="#D4AF37"
Foreground="#1F1A00"
BorderBrush="#F6E7A1"
BorderThickness="2" />
<!-- Badge for new photos -->
<Border x:Name="NewPhotosBadge"
Background="#E61A1A1A"
BorderBrush="#FF0000"
BorderThickness="2"
CornerRadius="12"
Width="36"
Height="36"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,-8,-8,0"
Visibility="Collapsed"
Panel.ZIndex="1">
<TextBlock x:Name="NewPhotoCountText"
Text="1"
Foreground="White"
FontSize="18"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
</Grid>
<!-- Gallery Prompt -->
<Border Grid.Row="0"
x:Name="GalleryPrompt"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="20"
VerticalAlignment="Top"
Margin="20,80,20,20"
Padding="16"
Background="#E61A1A1A"
BorderBrush="#66FFFFFF"
@ -147,15 +332,12 @@
FontWeight="SemiBold"
VerticalAlignment="Center"
Margin="0 0 12 0" />
<ui:Button Content="Jetzt in Galerie ansehen"
Click="OpenGalleryFromPrompt"
Width="240"
Height="52"
Background="#D4AF37"
Foreground="#1F1A00"
BorderBrush="#F6E7A1"
BorderThickness="2"
FontWeight="SemiBold" />
<Button Content="Jetzt in Galerie ansehen"
Click="OpenGalleryFromPrompt"
Style="{StaticResource PrimaryActionButtonStyle}"
Width="240"
Height="52"
FontWeight="SemiBold" />
</StackPanel>
</Border>
@ -205,11 +387,86 @@
</Border>
</Grid>
<!-- Loading Overlay -->
<Grid Grid.RowSpan="2"
x:Name="LoadingOverlay"
Background="#DD000000"
Panel.ZIndex="11">
<Border Background="#E61A1A1A"
BorderBrush="#66FFFFFF"
BorderThickness="1"
CornerRadius="20"
Padding="48"
MaxWidth="600"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="CamBooth wird geladen..."
Foreground="White"
FontSize="42"
FontWeight="Bold"
TextAlignment="Center"
Margin="0 0 0 30"/>
<!-- Loading Spinner -->
<Grid Width="100" Height="100" Margin="0 0 0 20">
<Ellipse Width="80" Height="80"
Stroke="#D4AF37"
StrokeThickness="6"
Opacity="0.3"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Ellipse x:Name="LoadingSpinner"
Width="80" Height="80"
Stroke="#D4AF37"
StrokeThickness="6"
StrokeDashArray="60 200"
StrokeDashCap="Round"
RenderTransformOrigin="0.5,0.5"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Ellipse.RenderTransform>
<RotateTransform x:Name="LoadingSpinnerTransform" Angle="0"/>
</Ellipse.RenderTransform>
<Ellipse.Triggers>
<EventTrigger RoutedEvent="Ellipse.Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetName="LoadingSpinnerTransform"
Storyboard.TargetProperty="Angle"
From="0" To="360"
Duration="0:0:1.5"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Ellipse.Triggers>
</Ellipse>
</Grid>
<TextBlock x:Name="LoadingStatusText"
Text="Fotos werden geladen..."
Foreground="#FFE8E8E8"
FontSize="20"
TextAlignment="Center"
TextWrapping="Wrap"
Margin="0 0 0 10"/>
<TextBlock x:Name="LoadingCountText"
Text="0 Fotos gefunden"
Foreground="#FFCCCCCC"
FontSize="16"
TextAlignment="Center"
Margin="0 0 0 0"/>
</StackPanel>
</Border>
</Grid>
<!-- Welcome Overlay -->
<Grid Grid.RowSpan="2"
x:Name="WelcomeOverlay"
Background="#CC000000"
Panel.ZIndex="10">
Panel.ZIndex="10"
Visibility="Collapsed">
<Border Background="#E61A1A1A"
BorderBrush="#66FFFFFF"
BorderThickness="1"
@ -232,6 +489,7 @@
TextWrapping="Wrap"
Margin="0 0 0 16"/>
<ui:Button Click="StartExperience"
Style="{StaticResource PrimaryActionButtonStyle}"
Width="480"
Height="100"
HorizontalAlignment="Center"

View File

@ -1,8 +1,10 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
@ -74,7 +76,10 @@ public partial class MainWindow : Window
InitializeComponent();
this.SetVisibilityDebugConsole(_appSettings.IsDebugConsoleVisible);
this.SetVisibilityPicturePanel(this._isPicturePanelVisible);
_ = this._pictureGalleryService.LoadThumbnailsToCache();
// Lade Thumbnails asynchron und zeige dann den Welcome Screen
_ = InitializeAsync();
this.Closing += OnClosing;
TimerControlRectangleAnimation.OnTimerEllapsed += TimerControlRectangleAnimation_OnTimerEllapsed;
this._focusStatusAnimationTimer.Tick += (_, _) =>
@ -112,6 +117,114 @@ public partial class MainWindow : Window
logger.Info("MainWindow initialized");
}
private async Task InitializeAsync()
{
try
{
// Zeige Ladeanzeige
this.LoadingOverlay.Visibility = Visibility.Visible;
this.WelcomeOverlay.Visibility = Visibility.Collapsed;
// Lade Thumbnails mit Progress-Updates
await LoadThumbnailsWithProgress();
// Warte kurz, damit der Benutzer die Fertigstellung sehen kann
await Task.Delay(500);
// Verstecke Ladeanzeige und zeige Welcome Screen
this.Dispatcher.Invoke(() =>
{
this.LoadingOverlay.Visibility = Visibility.Collapsed;
this.WelcomeOverlay.Visibility = Visibility.Visible;
});
this._logger.Info("Initialization completed successfully");
}
catch (Exception ex)
{
this._logger.Error($"Initialization error: {ex.Message}");
// Bei Fehler trotzdem zum Welcome Screen wechseln
this.Dispatcher.Invoke(() =>
{
this.LoadingOverlay.Visibility = Visibility.Collapsed;
this.WelcomeOverlay.Visibility = Visibility.Visible;
});
}
}
private async Task LoadThumbnailsWithProgress()
{
string pictureLocation = this._appSettings.PictureLocation;
// Sicherstellen, dass das Verzeichnis existiert
if (!Directory.Exists(pictureLocation))
{
this._logger.Info($"Picture directory does not exist: '{pictureLocation}'. Creating it...");
try
{
Directory.CreateDirectory(pictureLocation);
this._logger.Info($"Picture directory created: '{pictureLocation}'");
this.Dispatcher.Invoke(() =>
{
this.LoadingStatusText.Text = "Keine Fotos gefunden";
this.LoadingCountText.Text = "0 Fotos";
});
await Task.Delay(1000);
return;
}
catch (Exception ex)
{
this._logger.Error($"Failed to create picture directory: {ex.Message}");
this.Dispatcher.Invoke(() =>
{
this.LoadingStatusText.Text = "Fehler beim Erstellen des Foto-Ordners";
this.LoadingCountText.Text = "";
});
await Task.Delay(2000);
return;
}
}
// Zähle Bilddateien
string[] imageExtensions = { ".jpg", ".jpeg", ".png", ".bmp", ".gif" };
var picturePaths = Directory.EnumerateFiles(pictureLocation)
.Where(f => imageExtensions.Contains(Path.GetExtension(f).ToLower()))
.ToList();
int totalCount = picturePaths.Count;
if (totalCount == 0)
{
this._logger.Info($"No pictures found in directory: '{pictureLocation}'");
this.Dispatcher.Invoke(() =>
{
this.LoadingStatusText.Text = "Keine Fotos gefunden";
this.LoadingCountText.Text = "Bereit für neue Aufnahmen!";
});
await Task.Delay(1000);
return;
}
// Update UI mit Gesamtanzahl
this.Dispatcher.Invoke(() =>
{
this.LoadingStatusText.Text = $"Lade {totalCount} Foto{(totalCount != 1 ? "s" : "")}...";
this.LoadingCountText.Text = $"0 / {totalCount}";
});
// Lade Thumbnails
await this._pictureGalleryService.LoadThumbnailsToCache();
// Update UI nach dem Laden
this.Dispatcher.Invoke(() =>
{
this.LoadingStatusText.Text = "Fotos erfolgreich geladen!";
this.LoadingCountText.Text = $"{totalCount} Foto{(totalCount != 1 ? "s" : "")} bereit";
});
}
private void TimerControlRectangleAnimation_OnTimerEllapsed()
{
@ -147,20 +260,28 @@ public partial class MainWindow : Window
if (visibility)
{
this.HideGalleryPrompt();
this.PicturePanel.Navigate(new PictureGalleryPage(this._appSettings, this._logger, this._pictureGalleryService));
this.PicturePanel.Navigate(new PictureGalleryPage(this._appSettings, this._logger, this._pictureGalleryService, this._lycheeUploadService));
// Reset new photo count when opening gallery
this._pictureGalleryService.ResetNewPhotoCount();
// Blende unnötige Buttons aus, wenn Galerie geöffnet wird
this.ButtonPanel.Visibility = Visibility.Hidden;
this.ActionButtonsContainer.Visibility = Visibility.Hidden;
this.ShutdownDock.Visibility = Visibility.Hidden;
}
else
{
this.PicturePanel.ClearValue(MainWindow.ContentProperty);
// Stelle Buttons wieder her, wenn Galerie geschlossen wird
this.ButtonPanel.Visibility = Visibility.Visible;
this.ActionButtonsContainer.Visibility = Visibility.Visible;
this.ShutdownDock.Visibility = Visibility.Visible;
}
this._isPicturePanelVisible = !visibility;
}
private void ClosePicturePanel()
public void ClosePicturePanel()
{
if (this.PicturePanel.Content is PictureGalleryPage pictureGalleryPage)
{
@ -187,7 +308,7 @@ public partial class MainWindow : Window
this.StartLiveViewIfNeeded();
this.WelcomeOverlay.Visibility = Visibility.Collapsed;
this.ButtonPanel.Visibility = Visibility.Visible;
this.PictureGalleryDock.Visibility = Visibility.Visible;
this.ActionButtonsContainer.Visibility = Visibility.Visible;
this.ShutdownDock.Visibility = Visibility.Visible;
}
@ -284,11 +405,11 @@ public partial class MainWindow : Window
private void SwitchButtonAndTimerPanel()
{
this.ButtonPanel.Visibility = this.ButtonPanel.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
this.PictureGalleryDock.Visibility = this.PictureGalleryDock.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
this.ActionButtonsContainer.Visibility = this.ActionButtonsContainer.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
this.TimerPanel.Visibility = this.TimerPanel.Visibility == Visibility.Hidden ? Visibility.Visible : Visibility.Hidden;
}
private void SetVisibilityPicturePanel(object sender, RoutedEventArgs e)
private void SetVisibilityPicturePanel(object sender, MouseButtonEventArgs e)
{
this.SetVisibilityPicturePanel(this._isPicturePanelVisible);
}
@ -339,8 +460,24 @@ public partial class MainWindow : Window
System.Windows.MessageBox.Show("Windows konnte nicht heruntergefahren werden. Bitte erneut versuchen.");
}
}
this.Close();
else
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "shutdown",
Arguments = "/l", // = logoff (abmelden)
CreateNoWindow = true,
UseShellExecute = false
});
}
catch (Exception exception)
{
this._logger.Error(exception.Message);
System.Windows.MessageBox.Show("Abmeldung fehlgeschlagen");
}
}
}
private void ToggleShutdownSlider(object sender, RoutedEventArgs e)
@ -454,4 +591,33 @@ public partial class MainWindow : Window
this.NewPhotosBadge.Visibility = Visibility.Collapsed;
}
}
private void ShowQRCode(object sender, MouseButtonEventArgs e)
{
try
{
this._logger.Info("Zeige QR-Code an...");
// Generiere QR-Code
var qrCodeImage = this._lycheeUploadService.GenerateGalleryQRCode();
if (qrCodeImage == null)
{
System.Windows.MessageBox.Show("QR-Code konnte nicht generiert werden. Bitte überprüfe die Lychee-Konfiguration.",
"Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
return;
}
// Öffne QR-Code-Fenster
var qrWindow = new QRCodeDisplayWindow();
qrWindow.SetQRCode(qrCodeImage);
qrWindow.ShowDialog();
}
catch (Exception ex)
{
this._logger.Error($"Fehler beim Anzeigen des QR-Codes: {ex.Message}");
System.Windows.MessageBox.Show($"Fehler beim Anzeigen des QR-Codes: {ex.Message}",
"Fehler", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
}
}
}

View File

@ -1,115 +1,263 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Runder Button Stil mit 3D-Effekt, Wellenbewegung und Schatten -->
<Style x:Key="ModernRounded3DButtonStyle" TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<!-- 3D Hintergrund -->
<Ellipse>
<Ellipse.Fill>
<LinearGradientBrush StartPoint="0.3,0" EndPoint="0.7,1">
<GradientStop Color="#FFB22222" Offset="0.0" /> <!-- Obere Kante -->
<GradientStop Color="#FF8B0000" Offset="1.0" /> <!-- Untere Kante -->
</LinearGradientBrush>
</Ellipse.Fill>
<Ellipse.Effect>
<DropShadowEffect Color="Black"
BlurRadius="20"
ShadowDepth="7"
Opacity="0.6" />
</Ellipse.Effect>
</Ellipse>
<!-- Glanzeffekt (Highlight) -->
<Ellipse>
<Ellipse.Fill>
<RadialGradientBrush GradientOrigin="0.5,0.3" Center="0.5,0.3" RadiusX="1" RadiusY="1">
<GradientStop Color="#90EEEEEE" Offset="0.0" />
<GradientStop Color="#00EEEEEE" Offset="1.0" />
</RadialGradientBrush>
</Ellipse.Fill>
<Ellipse.OpacityMask>
<RadialGradientBrush GradientOrigin="0.5,0.5">
<GradientStop Color="White" Offset="0.6" />
<GradientStop Color="Transparent" Offset="0.8" />
</RadialGradientBrush>
</Ellipse.OpacityMask>
<!-- Dynamische Größe -->
<Ellipse.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="0.6" />
</Ellipse.RenderTransform>
</Ellipse>
<!-- Rechteckiger Button mit runden Ecken und 3D-Effekt -->
<Style x:Key="ModernRounded3DButtonStyle" TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<!-- 3D Hintergrund mit abgerundeten Ecken -->
<Border CornerRadius="20" Background="#FF8B0000"
BorderBrush="Transparent"
BorderThickness="0">
<Border.Effect>
<DropShadowEffect Color="Black"
BlurRadius="20"
ShadowDepth="7"
Opacity="0.6" />
</Border.Effect>
</Border>
<!-- Content (Button-Text) -->
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
TextElement.FontSize="32"
TextElement.FontWeight="Bold"
/>
<!-- Gradient Überlagerung für 3D-Effekt -->
<Border CornerRadius="20" Background="#FFB22222" Opacity="0.3" />
<!-- Wellenkreis -->
<Ellipse Name="RippleEffect"
Fill="White"
Opacity="0"
Width="0"
Height="0"
RenderTransformOrigin="0.5,0.5">
<Ellipse.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</Ellipse.RenderTransform>
</Ellipse>
</Grid>
<!-- Glanzeffekt -->
<Border CornerRadius="20">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,0.3">
<GradientStop Color="#80FFFFFF" Offset="0.0" />
<GradientStop Color="#00FFFFFF" Offset="0.8" />
</LinearGradientBrush>
</Border.Background>
</Border>
<ControlTemplate.Triggers>
<!-- Hover-Effekt -->
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="#FF4500" />
<Setter TargetName="RippleEffect" Property="Opacity" Value="0.2" />
</Trigger>
<!-- Content (Button-Text) -->
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
TextElement.FontSize="32"
TextElement.FontWeight="Bold" />
<!-- Klick-Effekt mit Wellen -->
<Trigger Property="IsPressed" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<!-- Wellenbewegung -->
<DoubleAnimation Storyboard.TargetName="RippleEffect"
Storyboard.TargetProperty="Opacity"
From="0.3" To="0" Duration="0:0:0.5" />
<DoubleAnimation Storyboard.TargetName="RippleEffect"
Storyboard.TargetProperty="Width"
From="0" To="200" Duration="0:0:0.5" />
<DoubleAnimation Storyboard.TargetName="RippleEffect"
Storyboard.TargetProperty="Height"
From="0" To="200" Duration="0:0:0.5" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Setter Property="Background" Value="#D32F2F" />
</Trigger>
<!-- Ripple-Effekt -->
<Ellipse Name="RippleEffect"
Fill="White"
Opacity="0"
Width="0"
Height="0"
RenderTransformOrigin="0.5,0.5">
<Ellipse.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</Ellipse.RenderTransform>
</Ellipse>
</Grid>
<!-- 3D Effekt beim Loslassen -->
<Trigger Property="IsPressed" Value="False">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<!-- Bewegung nach oben simulieren -->
<DoubleAnimation Storyboard.TargetProperty="RenderTransform.(TranslateTransform.Y)"
To="-2"
Duration="0:0:0.2"
AutoReverse="True" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate.Triggers>
<!-- Hover-Effekt -->
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="#FF4500" />
<Setter TargetName="RippleEffect" Property="Opacity" Value="0.2" />
</Trigger>
<!-- Klick-Effekt mit Wellen -->
<Trigger Property="IsPressed" Value="True">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<!-- Wellenbewegung -->
<DoubleAnimation Storyboard.TargetName="RippleEffect"
Storyboard.TargetProperty="Opacity"
From="0.3" To="0" Duration="0:0:0.5" />
<DoubleAnimation Storyboard.TargetName="RippleEffect"
Storyboard.TargetProperty="Width"
From="0" To="200" Duration="0:0:0.5" />
<DoubleAnimation Storyboard.TargetName="RippleEffect"
Storyboard.TargetProperty="Height"
From="0" To="200" Duration="0:0:0.5" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Setter Property="Background" Value="#D32F2F" />
</Trigger>
<!-- 3D Effekt beim Loslassen -->
<Trigger Property="IsPressed" Value="False">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<!-- Bewegung nach oben simulieren -->
<DoubleAnimation
Storyboard.TargetProperty="RenderTransform.(TranslateTransform.Y)"
To="-2"
Duration="0:0:0.2"
AutoReverse="True" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Icon Button Style Basis -->
<Style x:Key="IconButtonStyle" TargetType="Button" BasedOn="{StaticResource ModernRounded3DButtonStyle}">
<Setter Property="Padding" Value="16,12" />
<Setter Property="ContentTemplate">
<Setter.Value>
<DataTemplate>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ContentPresenter Content="{TemplateBinding Content}" />
</StackPanel>
</DataTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Nur Icon Buttons (ohne Text) -->
<Style x:Key="IconOnlyButtonStyle" TargetType="Button" BasedOn="{StaticResource ModernRounded3DButtonStyle}">
<Setter Property="Width" Value="60" />
<Setter Property="Height" Value="60" />
<Setter Property="Padding" Value="0" />
</Style>
<!-- Material Design Icon Button Style -->
<Style x:Key="MaterialIconButtonStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="8" />
<Setter Property="Width" Value="50" />
<Setter Property="Height" Value="50" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontSize" Value="24" />
<Setter Property="FontFamily" Value="Segoe MDL2 Assets" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid Background="Transparent">
<Ellipse Name="HoverEllipse" Fill="White" Opacity="0" />
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Content}" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="HoverEllipse" Property="Opacity" Value="0.15" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="HoverEllipse" Property="Opacity" Value="0.25" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Colored Material Icon Button Style -->
<Style x:Key="MaterialIconButtonColoredStyle" TargetType="Button" BasedOn="{StaticResource MaterialIconButtonStyle}">
<Setter Property="Width" Value="60" />
<Setter Property="Height" Value="60" />
<Setter Property="FontSize" Value="28" />
<Setter Property="Foreground" Value="#1F1A00" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Grid>
<Ellipse Name="BackgroundEllipse" Fill="#FFD700" />
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Content}"
TextElement.Foreground="{TemplateBinding Foreground}" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="BackgroundEllipse" Property="Opacity" Value="0.8" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="BackgroundEllipse" Property="Opacity" Value="0.6" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Primary Action Button Style (Gold mit Rounded) -->
<Style x:Key="PrimaryActionButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#D4AF37" />
<Setter Property="Foreground" Value="#1F1A00" />
<Setter Property="BorderBrush" Value="#F6E7A1" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="Padding" Value="16,8" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#F0C847" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#C49B2E" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Secondary Button Style (Dark mit Border) -->
<Style x:Key="SecondaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#3C3C3C" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderBrush" Value="#555555" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="16,8" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#4C4C4C" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#2C2C2C" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Paging Button Style (für Bildergalerie) -->
<Style x:Key="PagingButtonStyle" TargetType="Button" BasedOn="{StaticResource SecondaryButtonStyle}">
<Setter Property="Width" Value="60" />
<Setter Property="Height" Value="50" />
<Setter Property="FontSize" Value="20" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="5,0" />
</Style>
</ResourceDictionary>

View File

@ -6,6 +6,8 @@
- Energiesparmodus abschalten
- Starbildschirm mit freundlicher Begrüßung, kurzer Erklärung, und viel Spaß wünschen mit der FotoCam
- Verschiedene Hinweise anzeigen beim Fotografieren (lächeln, Hasensohren, Zunge raus, Grimasse, usw.)
- Bild über QR Code runterladen
- Bild über QR Code runterladen (QR Code anzeigen, sowie ausdrucken und anklebene)
- Windows updates deaktivieren
- logging einbinden (Elastic order ähnliches)
- Router anschließen für Upload
- Configs kontrollieren auf Fotobox

View File

@ -0,0 +1,172 @@
# SESSION_NOT_OPEN Error - Root Cause and Fix
## Problem Summary
The application was failing with `SESSION_NOT_OPEN` error when trying to start the live view camera feed:
```
Failed to start live view: SESSION_NOT_OPEN at EOSDigital.API.STAThread.Invoke(Action action)
at EOSDigital.API.Camera.OpenSession()
at CamBooth.App.Features.Camera.CameraService.OpenSession()
```
## Root Cause Analysis
### Issue 1: Uninitialized Camera Instance at Startup
In `App.xaml.cs`, the `ICamera` dependency is registered as a **Singleton at application startup**:
```csharp
services.AddSingleton<ICamera, Camera>();
```
This creates a single Camera instance **before any actual physical camera is connected**, and this uninitialized instance is never properly updated when cameras are detected later.
### Issue 2: Stale Camera Reference
The `CameraService` constructor receives this uninitialized `_mainCamera` instance:
```csharp
public CameraService(..., ICamera mainCamera, ICanonAPI APIHandler)
{
this._mainCamera = mainCamera; // This is the uninitialized singleton
...
}
```
### Issue 3: Incorrect Flow in ConnectCamera()
The original `ConnectCamera()` method called `OpenSession()` **before reassigning** `_mainCamera`:
```csharp
public void ConnectCamera()
{
this.RefreshCamera(); // Gets camera list
List<ICamera> cameraList = this._APIHandler.GetCameraList();
if (cameraList.Any())
{
this.OpenSession(); // ← Uses OLD _mainCamera, not the newly detected one!
// ...
}
}
```
The `OpenSession()` method did try to reassign `_mainCamera`, but the error was thrown **before reaching that point** in some error conditions.
## The Fix
### Change 1: Update ConnectCamera() Method
Moved the `_mainCamera` assignment **before** calling `OpenSession()`:
```csharp
public void ConnectCamera()
{
ErrorHandler.SevereErrorHappened += this.ErrorHandler_SevereErrorHappened;
ErrorHandler.NonSevereErrorHappened += this.ErrorHandler_NonSevereErrorHappened;
try
{
this.RefreshCamera();
// Retry logic for camera detection (some systems need time to initialize)
int maxRetries = 3;
int retryDelay = 500; // milliseconds
for (int attempt = 0; attempt < maxRetries; attempt++)
{
if (this.CamList != null && this.CamList.Any())
{
break;
}
if (attempt < maxRetries - 1)
{
this._logger.Info($"No cameras found on attempt {attempt + 1}/{maxRetries}, retrying in {retryDelay}ms...");
System.Threading.Thread.Sleep(retryDelay);
this.RefreshCamera();
}
}
if (this.CamList == null || !this.CamList.Any())
{
this.ReportError("No cameras / devices found");
throw new InvalidOperationException("No cameras / devices found after multiple attempts");
}
this._logger.Info($"Found {this.CamList.Count} camera(s)");
string cameraDeviceNames = string.Join(", ", this.CamList.Select(cam => cam.DeviceName));
this._logger.Info(cameraDeviceNames);
// ← UPDATE _mainCamera BEFORE calling OpenSession()
this._mainCamera = this.CamList[0];
this._logger.Info($"Selected camera: {this._mainCamera.DeviceName}");
this.OpenSession();
this.SetSettingSaveToComputer();
this.StarLiveView();
}
catch (Exception ex)
{
this._logger.Error($"Error connecting camera: {ex.Message}");
throw;
}
}
```
**Key improvements:**
1. **Explicit camera assignment before OpenSession()** - Ensures the correct physical camera is used
2. **Retry logic** - Handles SDK initialization delays on some systems
3. **Better error messages** - Clearer logging of the connection process
### Change 2: Simplify OpenSession() Method
Removed redundant camera reassignment and simplified validation:
```csharp
private void OpenSession()
{
try
{
if (this._mainCamera == null)
{
throw new InvalidOperationException("Camera reference is null. Make sure ConnectCamera is called first.");
}
// Check if session is already open
if (this._mainCamera.SessionOpen)
{
this._logger.Info($"Camera session already open for {this._mainCamera.DeviceName}");
return;
}
this._logger.Info($"Opening session for camera: {this._mainCamera.DeviceName}");
this._mainCamera.OpenSession();
this._logger.Info("Camera session opened successfully");
this._mainCamera.StateChanged += this.MainCamera_StateChanged;
this._mainCamera.DownloadReady += this.MainCamera_DownloadReady;
this.AvList = this._mainCamera.GetSettingsList(PropertyID.Av);
this.TvList = this._mainCamera.GetSettingsList(PropertyID.Tv);
this.ISOList = this._mainCamera.GetSettingsList(PropertyID.ISO);
}
catch (Exception ex)
{
this._logger.Error($"Failed to open camera session: {ex.Message}");
this.ReportError($"Failed to open camera session: {ex.Message}");
throw;
}
}
```
**Improvements:**
1. **No redundant camera assignment** - Camera is already set by ConnectCamera()
2. **Clearer null check** - Validates that ConnectCamera() was called first
3. **Maintained duplex open check** - Still checks if session is already open
## Files Modified
- `CamBooth.App\Features\Camera\CameraService.cs`
- `ConnectCamera()` method
- `OpenSession()` method
## Testing Recommendations
1. **Test with camera connected** - Verify live view starts successfully
2. **Test with camera disconnected** - Verify graceful error handling with retries
3. **Test with delayed camera detection** - Ensure retry logic gives SDK time to initialize
4. **Test camera reconnection** - Disconnect and reconnect camera to verify session management
## Additional Notes
- The singleton pattern for `ICamera` in dependency injection is not ideal, but this fix ensures the correct instance is used once a physical camera is detected
- The retry logic (3 attempts, 500ms delay) can be adjusted based on system requirements
- Consider future refactoring to use factory pattern for camera initialization instead of singleton