|
|
|
|
@ -1,23 +1,21 @@
|
|
|
|
|
<div class="flex flex-col items-center w-full h-full"
|
|
|
|
|
<div class="flex flex-col items-center w-full"
|
|
|
|
|
x-data="{ lightbox: null }"
|
|
|
|
|
x-on:keydown.escape.window="lightbox = null">
|
|
|
|
|
|
|
|
|
|
<div class="flex-1 min-h-0 w-full flex justify-center">
|
|
|
|
|
<div class="relative h-full" style="aspect-ratio: 16/9; max-width: 100%;">
|
|
|
|
|
<video id="video" autoplay playsinline
|
|
|
|
|
class="w-full h-full rounded-2xl shadow-2xl bg-gray-900" style="transform: scaleX(-1);"></video>
|
|
|
|
|
<div class="relative w-full max-w-2xl">
|
|
|
|
|
<video id="video" autoplay playsinline
|
|
|
|
|
class="w-full rounded-2xl shadow-2xl bg-gray-900 aspect-video object-cover" style="transform: scaleX(-1);"></video>
|
|
|
|
|
|
|
|
|
|
<canvas id="canvas" class="hidden"></canvas>
|
|
|
|
|
<canvas id="canvas" class="hidden"></canvas>
|
|
|
|
|
|
|
|
|
|
<div id="flash" class="absolute inset-0 rounded-2xl bg-white opacity-0 pointer-events-none transition-opacity duration-150"></div>
|
|
|
|
|
<div id="flash" class="absolute inset-0 rounded-2xl bg-white opacity-0 pointer-events-none transition-opacity duration-150"></div>
|
|
|
|
|
|
|
|
|
|
<div id="countdown" class="absolute inset-0 rounded-2xl hidden items-center justify-center bg-black/40">
|
|
|
|
|
<span id="countdown-num" class="text-white font-bold drop-shadow-lg" style="font-size: 8rem; line-height: 1;"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="countdown" class="absolute inset-0 rounded-2xl hidden items-center justify-center bg-black/40">
|
|
|
|
|
<span id="countdown-num" class="text-white font-bold drop-shadow-lg" style="font-size: 8rem; line-height: 1;"></span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="shrink-0 mt-6 w-full max-w-xs">
|
|
|
|
|
<div class="mt-6 w-full max-w-xs">
|
|
|
|
|
<select wire:model="photoprofileId"
|
|
|
|
|
class="w-full rounded-full bg-black/50 text-white border border-white/20 px-5 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-white/40 appearance-none text-center">
|
|
|
|
|
@foreach ($photoprofiles as $id => $name)
|
|
|
|
|
@ -26,18 +24,23 @@
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="camera-select-wrap" class="mt-3 w-full max-w-xs hidden">
|
|
|
|
|
<select id="camera-select"
|
|
|
|
|
class="w-full rounded-full bg-black/50 text-white border border-white/20 px-5 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-white/40 appearance-none text-center"></select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button id="capture-btn"
|
|
|
|
|
wire:loading.attr="disabled"
|
|
|
|
|
@disabled($hasProcessing)
|
|
|
|
|
class="shrink-0 mt-8 px-10 py-4 bg-white text-gray-950 font-semibold text-lg rounded-full shadow-lg hover:bg-gray-200 active:scale-95 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
|
|
|
class="mt-8 px-10 py-4 bg-white text-gray-950 font-semibold text-lg rounded-full shadow-lg hover:bg-gray-200 active:scale-95 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
|
|
|
<span wire:loading.remove>{{ $hasProcessing ? 'Erstelle Bild...' : 'Foto aufnehmen' }}</span>
|
|
|
|
|
<span wire:loading>Saving…</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div class="shrink-0 mt-4 text-sm text-gray-400 h-6">{{ $status }}</div>
|
|
|
|
|
<div class="mt-4 text-sm text-gray-400 h-6">{{ $status }}</div>
|
|
|
|
|
|
|
|
|
|
@if ($recentPhotos->isNotEmpty())
|
|
|
|
|
<div class="shrink-0 mt-10 w-full max-w-2xl">
|
|
|
|
|
<div class="mt-10 w-full max-w-2xl">
|
|
|
|
|
<h2 class="text-sm font-medium text-gray-400 mb-3 tracking-wide uppercase">Recent Photos</h2>
|
|
|
|
|
<div class="grid grid-cols-5 gap-2" @if($hasProcessing) wire:poll.2000ms @endif>
|
|
|
|
|
@foreach ($recentPhotos as $photo)
|
|
|
|
|
@ -103,20 +106,73 @@
|
|
|
|
|
const flash = document.getElementById('flash');
|
|
|
|
|
const countdown = document.getElementById('countdown');
|
|
|
|
|
const countdownNum = document.getElementById('countdown-num');
|
|
|
|
|
const cameraSelectWrap = document.getElementById('camera-select-wrap');
|
|
|
|
|
const cameraSelect = document.getElementById('camera-select');
|
|
|
|
|
|
|
|
|
|
const CAMERA_STORAGE_KEY = 'photobox_camera_id';
|
|
|
|
|
|
|
|
|
|
function videoConstraints(deviceId) {
|
|
|
|
|
const base = { width: { ideal: 4096 }, height: { ideal: 2160 } };
|
|
|
|
|
return deviceId ? { ...base, deviceId: { exact: deviceId } } : base;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startCamera(deviceId) {
|
|
|
|
|
if (video.srcObject) {
|
|
|
|
|
video.srcObject.getTracks().forEach(track => track.stop());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startCamera() {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
video: { width: { ideal: 4096 }, height: { ideal: 2160 } },
|
|
|
|
|
video: videoConstraints(deviceId),
|
|
|
|
|
audio: false,
|
|
|
|
|
});
|
|
|
|
|
video.srcObject = stream;
|
|
|
|
|
} catch {
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (deviceId && err.name === 'OverconstrainedError') {
|
|
|
|
|
localStorage.removeItem(CAMERA_STORAGE_KEY);
|
|
|
|
|
return startCamera(null);
|
|
|
|
|
}
|
|
|
|
|
$wire.set('status', 'Camera access denied or unavailable.');
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function populateCameraList() {
|
|
|
|
|
let devices;
|
|
|
|
|
try {
|
|
|
|
|
devices = await navigator.mediaDevices.enumerateDevices();
|
|
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cameras = devices.filter(d => d.kind === 'videoinput');
|
|
|
|
|
if (cameras.length < 2) {
|
|
|
|
|
cameraSelectWrap.classList.add('hidden');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cameraSelect.innerHTML = '';
|
|
|
|
|
cameras.forEach((cam, index) => {
|
|
|
|
|
const option = document.createElement('option');
|
|
|
|
|
option.value = cam.deviceId;
|
|
|
|
|
option.textContent = cam.label || `Camera ${index + 1}`;
|
|
|
|
|
cameraSelect.appendChild(option);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const storedId = localStorage.getItem(CAMERA_STORAGE_KEY);
|
|
|
|
|
if (storedId && cameras.some(cam => cam.deviceId === storedId)) {
|
|
|
|
|
cameraSelect.value = storedId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cameraSelectWrap.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cameraSelect.addEventListener('change', () => {
|
|
|
|
|
localStorage.setItem(CAMERA_STORAGE_KEY, cameraSelect.value);
|
|
|
|
|
startCamera(cameraSelect.value);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function triggerFlash() {
|
|
|
|
|
flash.style.opacity = '0.8';
|
|
|
|
|
setTimeout(() => flash.style.opacity = '0', 150);
|
|
|
|
|
@ -151,7 +207,10 @@
|
|
|
|
|
await $wire.capture(imageData);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
startCamera();
|
|
|
|
|
(async () => {
|
|
|
|
|
await startCamera(localStorage.getItem(CAMERA_STORAGE_KEY));
|
|
|
|
|
await populateCameraList();
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
@endscript
|
|
|
|
|
</div>
|
|
|
|
|
|