@props([ // The Livewire array property the files are uploaded into. The component // that owns it is expected to save on update and reset, so a capture lands // immediately rather than waiting for a form submit. 'target' => 'media', // Which ways of capturing to offer. A voice note needs a microphone, so it // is worth hiding where it makes no sense. 'photo' => true, 'video' => true, 'voice' => true, 'files' => true, 'label' => null, 'hint' => null, // Upload ceiling in kilobytes, checked in the browser before a capture is // sent so a five-minute clip fails at the shutter rather than after the // upload. Null skips the check and leaves it to the server. 'maxKb' => null, // Longest single recording, in seconds. Recording stops itself here. 'maxSeconds' => 300, ]) {{-- Capture happens ON the page, in a viewfinder, not by handing off to the phone's camera app. The handoff route (a file input with `capture`) reads well on paper and fails in the field: Android puts the camera in the foreground, the browser tab is dropped under memory pressure, and returning to a reloaded page leaves the shot nowhere — the person swears they took it and the job has no evidence. Nothing in the page can prevent that, because the page is not running at the time. getUserMedia keeps the tab alive and in charge the whole way through, which also buys the thing that route could never offer: see the shot before it is filed, and retake it if the sample is out of focus. The handoff is still there as a fallback for a browser with no getUserMedia — an old iOS, or the site served over plain HTTP, where the API is simply absent. That is a real case, so it degrades rather than disappearing. --}}
merge(['class' => 'rounded-xl border border-slate-200 bg-slate-50/70 p-3']) }} x-data="{ target: @js($target), maxKb: @js($maxKb ? (int) $maxKb : null), maxSeconds: @js((int) $maxSeconds), // Which studio is open: null (closed), 'photo', 'video' or 'voice'. mode: null, stream: null, recorder: null, chunks: [], recording: false, starting: false, seconds: 0, timer: null, facing: 'environment', canFlip: false, // The capture waiting to be kept or thrown away: { file, kind, url }. shot: null, error: '', notice: '', camOk() { return !! (navigator.mediaDevices && navigator.mediaDevices.getUserMedia); }, recOk() { return this.camOk() && typeof window.MediaRecorder !== 'undefined'; }, fmt(s) { const m = Math.floor(s / 60), r = s % 60; return m + ':' + (r < 10 ? '0' : '') + r; }, stamp() { return new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); }, /* --- opening and closing the studio ---------------------------- */ async open(mode) { // No in-browser camera (old iOS, or the site on plain HTTP): fall // back to the phone's own camera app rather than offering nothing. const need = mode === 'photo' ? this.camOk() : this.recOk(); if (! need) { this.fallback(mode); return; } this.error = ''; this.notice = ''; this.mode = mode; this.discard(); await this.$nextTick(); await this.begin(); }, fallback(mode) { const input = mode === 'photo' ? this.$refs.photoInput : mode === 'video' ? this.$refs.videoInput : null; if (input) { this.notice = @js(__('This browser cannot record inside the page, so your phone camera will open instead.')); input.click(); return; } this.error = @js(__('This device cannot record audio in the browser. Record it in your voice recorder app and upload the file.')); }, async begin() { this.starting = true; this.error = ''; try { this.stream = await navigator.mediaDevices.getUserMedia( this.mode === 'voice' ? { audio: true } : { audio: this.mode === 'video', video: { facingMode: { ideal: this.facing }, width: { ideal: 1920 }, height: { ideal: 1080 }, }, } ); if (this.mode !== 'voice' && this.$refs.viewfinder) { this.$refs.viewfinder.srcObject = this.stream; // iOS refuses to autoplay without the gesture chain; the // button press is that gesture, so this resolves. try { await this.$refs.viewfinder.play(); } catch (e) {} } this.sniffCameras(); } catch (e) { this.error = this.reason(e); } finally { this.starting = false; } }, reason(e) { const n = e && e.name ? e.name : ''; if (n === 'NotAllowedError' || n === 'SecurityError') { return @js(__('Permission was refused. Allow the camera and microphone for this site in your browser settings, then try again.')); } if (n === 'NotFoundError' || n === 'OverconstrainedError') { return @js(__('No camera or microphone was found on this device.')); } if (n === 'NotReadableError') { return @js(__('The camera is already in use by another app. Close it and try again.')); } return @js(__('The camera could not be started on this device.')); }, async sniffCameras() { if (this.mode === 'voice' || ! navigator.mediaDevices.enumerateDevices) return; try { const devices = await navigator.mediaDevices.enumerateDevices(); this.canFlip = devices.filter(d => d.kind === 'videoinput').length > 1; } catch (e) { this.canFlip = false; } }, async flip() { this.facing = this.facing === 'environment' ? 'user' : 'environment'; this.release(); await this.begin(); }, release() { if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; } if (this.$refs.viewfinder) this.$refs.viewfinder.srcObject = null; }, discard() { if (this.shot && this.shot.url) URL.revokeObjectURL(this.shot.url); this.shot = null; }, close() { if (this.recording && this.recorder) { // Drop what was being recorded rather than filing it. this.recorder.onstop = null; try { this.recorder.stop(); } catch (e) {} } this.recording = false; clearInterval(this.timer); this.seconds = 0; this.release(); this.discard(); this.mode = null; this.notice = ''; }, /* --- taking the shot -------------------------------------------- */ shoot() { const v = this.$refs.viewfinder; if (! v || ! v.videoWidth) return; const canvas = document.createElement('canvas'); canvas.width = v.videoWidth; canvas.height = v.videoHeight; canvas.getContext('2d').drawImage(v, 0, 0, canvas.width, canvas.height); canvas.toBlob(blob => { if (! blob) { this.error = @js(__('The photo could not be saved. Try again.')); return; } this.keep(new File([blob], 'photo-' + this.stamp() + '.jpg', { type: 'image/jpeg' }), 'image'); this.release(); }, 'image/jpeg', 0.92); }, pickMime() { const wanted = this.mode === 'voice' ? ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'] : ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4']; if (! window.MediaRecorder.isTypeSupported) return ''; return wanted.find(t => MediaRecorder.isTypeSupported(t)) || ''; }, extFor(type) { if (type.indexOf('mp4') !== -1) return this.mode === 'voice' ? 'm4a' : 'mp4'; if (type.indexOf('ogg') !== -1) return 'ogg'; return 'webm'; }, record() { if (! this.stream) return; this.error = ''; this.chunks = []; const mime = this.pickMime(); try { this.recorder = new MediaRecorder(this.stream, mime ? { mimeType: mime } : {}); } catch (e) { this.error = @js(__('This browser cannot record here. Use the upload button instead.')); return; } this.recorder.ondataavailable = e => { if (e.data && e.data.size) this.chunks.push(e.data); }; this.recorder.onstop = () => { clearInterval(this.timer); this.recording = false; const type = this.recorder.mimeType || mime || (this.mode === 'voice' ? 'audio/webm' : 'video/webm'); const blob = new Blob(this.chunks, { type }); const name = (this.mode === 'voice' ? 'voice-note-' : 'clip-') + this.stamp() + '.' + this.extFor(type); this.keep(new File([blob], name, { type }), this.mode === 'voice' ? 'audio' : 'video'); this.release(); }; this.recorder.start(); this.recording = true; this.seconds = 0; this.timer = setInterval(() => { this.seconds++; // Stop itself rather than let someone film until the upload // ceiling rejects the whole thing. if (this.seconds >= this.maxSeconds) this.stop(); }, 1000); }, stop() { if (this.recorder && this.recording) { try { this.recorder.stop(); } catch (e) {} } }, /* --- keeping it -------------------------------------------------- */ keep(file, kind) { this.shot = { file: file, kind: kind, url: URL.createObjectURL(file), size: file.size }; }, tooBig() { return !! (this.shot && this.maxKb && this.shot.size > this.maxKb * 1024); }, humanSize(bytes) { const units = ['B', 'KB', 'MB', 'GB']; let i = 0; while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } return (i === 0 ? bytes : bytes.toFixed(1)) + ' ' + units[i]; }, async retake() { this.discard(); this.error = ''; await this.$nextTick(); await this.begin(); }, fileIt() { if (! this.shot) return; if (this.tooBig()) { this.error = @js(__('That is too big to upload. Record a shorter clip.')); return; } // A capture has no file input behind it, so Livewire's own // start/finish/error events — the ones every spinner and progress // bar in the app listens to — are never raised for it. Raise them // here, from the component root, so a photo taken in the page is // reported exactly like a file picked off the device. Without the // finish, a capture would also leave the stall watchdog to clean up // after an upload that actually worked. const el = this.$el; const id = this.$wire.$id; const property = this.target; const shot = this.shot; const fire = (name, detail = {}) => el.dispatchEvent( new CustomEvent(name, { bubbles: true, detail: { id, property, ...detail } }) ); fire('livewire-upload-start', { files: [{ name: shot.file.name, size: shot.size }] }); this.$wire.uploadMultiple( property, [shot.file], () => fire('livewire-upload-finish'), () => fire('livewire-upload-error'), (e) => fire('livewire-upload-progress', { progress: e?.detail?.progress ?? 0 }), () => fire('livewire-upload-cancel'), ); this.close(); }, }" x-on:beforeunload.window="release()"> @if ($label)

{{ $label }}

@endif
@if ($photo) @endif @if ($video) @endif @if ($voice) @endif @if ($files) @endif {{ __('Uploading…') }}
@if ($hint)

{{ $hint }}

@endif

{{-- ============ THE STUDIO ============ Teleported to the body so it is a real full-screen surface on a phone rather than something clipped by the card it was opened from. --}}