UNPKG

@uploadcare/file-uploader

Version:

Building blocks for Uploadcare products integration

924 lines (758 loc) 25.5 kB
//@ts-nocheck import { ActivityBlock } from '../../abstract/ActivityBlock.js'; import { UploaderBlock } from '../../abstract/UploaderBlock.js'; import { stringToArray } from '../../utils/stringToArray.js'; import { canUsePermissionsApi } from '../utils/abilities.js'; import { deserializeCsv } from '../utils/comma-separated.js'; import { debounce } from '../utils/debounce.js'; import { UploadSource } from '../utils/UploadSource.js'; import { CameraSourceEvents, CameraSourceTypes } from './constants.js'; const DEFAULT_VIDEO_CONFIG = { width: { ideal: 1920, }, height: { ideal: 1080, }, frameRate: { ideal: 30, }, }; const DEFAULT_PERMISSIONS = ['camera', 'microphone']; /** * @param {Number} time * @returns */ function formatTime(time) { const minutes = Math.floor(time / 60) .toString() .padStart(2, '0'); const seconds = Math.floor(time % 60) .toString() .padStart(2, '0'); return `${minutes}:${seconds}`; } const DEFAULT_PICTURE_FORMAT = 'image/jpeg'; const DEFAULT_VIDEO_FORMAT = 'video/webm'; /** @typedef {'photo' | 'video'} CameraMode */ /** @typedef {'shot' | 'retake' | 'accept' | 'play' | 'stop' | 'pause' | 'resume'} CameraStatus */ export class CameraSource extends UploaderBlock { couldBeCtxOwner = true; activityType = ActivityBlock.activities.CAMERA; /** @private */ _unsubPermissions = null; /** @type {BlobPart[]} */ _chunks = []; /** @type {MediaRecorder | null} */ _mediaRecorder = null; /** @type {MediaStream | null} */ _stream = null; /** @type {string | null} */ _selectedAudioId = null; /** @type {string | null} */ _selectedCameraId = null; constructor() { super(); this.init$ = { ...this.init$, video: null, videoTransformCss: null, videoHidden: true, messageHidden: true, requestBtnHidden: canUsePermissionsApi(), cameraSelectOptions: null, cameraSelectHidden: true, l10nMessage: '', // This is refs switcher: null, panels: null, timer: null, timerHidden: true, cameraHidden: true, cameraActionsHidden: true, audioSelectOptions: null, audioSelectHidden: true, audioSelectDisabled: true, audioToggleMicrophoneHidden: true, tabCameraHidden: true, tabVideoHidden: true, currentIcon: 'camera-full', currentTimelineIcon: 'play', toggleMicrophoneIcon: 'microphone', /** @type {Number} */ _startTime: 0, /** @type {Number} */ _elapsedTime: 0, _animationFrameId: null, mutableClassButton: 'uc-shot-btn uc-camera-action', /** @param {Event} e */ onCameraSelectChange: (e) => { this._selectedCameraId = e.target.value; this._capture(); }, /** @param {Event} e */ onAudioSelectChange: (e) => { this._selectedAudioId = e.target.value; this._capture(); }, onCancel: () => { this.historyBack(); }, onShot: () => this._shot(), onRequestPermissions: () => this._capture(), /** General method for photo and video capture */ onStartCamera: () => this._chooseActionWithCamera(), onStartRecording: () => this._startRecording(), onStopRecording: () => this._stopRecording(), onToggleRecording: () => this._toggleRecording(), onToggleAudio: () => this._toggleEnableAudio(), onRetake: () => this._retake(), onAccept: () => this._accept(), /** @param {MouseEvent} e */ onClickTab: (e) => { const id = /** @type {HTMLElement} */ (e.currentTarget).getAttribute('data-id'); if (id) this._handleActiveTab(/** @type {CameraMode} */ (id)); }, }; } _chooseActionWithCamera = () => { if (this._activeTab === CameraSourceTypes.PHOTO) { this._shot(); } if (this._activeTab === CameraSourceTypes.VIDEO) { if (this._mediaRecorder?.state === 'recording') { this._stopRecording(); return; } this._startRecording(); } }; _updateTimer = () => { const currentTime = Math.floor((performance.now() - this.$._startTime + this.$._elapsedTime) / 1000); if (typeof this.cfg.maxVideoRecordingDuration === 'number' && this.cfg.maxVideoRecordingDuration > 0) { const remainingTime = this.cfg.maxVideoRecordingDuration - currentTime; if (remainingTime <= 0) { this.ref.timer.textContent = formatTime(remainingTime); this._stopRecording(); return; } this.ref.timer.textContent = formatTime(remainingTime); } else { this.ref.timer.textContent = formatTime(currentTime); } this._animationFrameId = requestAnimationFrame(this._updateTimer); }; _startTimer = () => { this.$._startTime = performance.now(); this.$._elapsedTime = 0; this._updateTimer(); }; _stopTimer = () => { if (this._animationFrameId) cancelAnimationFrame(this._animationFrameId); }; _startTimeline = () => { const currentTime = this.ref.video.currentTime; const duration = this.ref.video.duration; this.ref.line.style.transform = `scaleX(${currentTime / duration})`; this.ref.timer.textContent = formatTime(currentTime); this._animationFrameId = requestAnimationFrame(this._startTimeline); }; _stopTimeline = () => { if (this._animationFrameId) cancelAnimationFrame(this._animationFrameId); }; _startRecording = () => { try { this._chunks = []; this._options = { ...this.cfg.mediaRecorderOptions, }; const { mimeType } = this.cfg.mediaRecorderOptions || {}; if (mimeType && MediaRecorder.isTypeSupported(mimeType)) { this._options.mimeType = mimeType; } else if (MediaRecorder.isTypeSupported(DEFAULT_VIDEO_FORMAT)) { this._options.mimeType = DEFAULT_VIDEO_FORMAT; } else { this._options.mimeType = 'video/mp4'; } if (this._stream) { this._mediaRecorder = new MediaRecorder(this._stream, this._options); this._mediaRecorder.start(); this._mediaRecorder.addEventListener('dataavailable', (e) => { this._chunks.push(e.data); }); this._startTimer(); this.classList.add('uc-recording'); this._setCameraState(CameraSourceEvents.PLAY); } } catch (error) { console.error('Failed to start recording', error); } }; /** @private */ _stopRecording = () => { this._mediaRecorder?.addEventListener('stop', () => { this._previewVideo(); this._stopTimer(); this._setCameraState(CameraSourceEvents.STOP); }); this._mediaRecorder?.stop(); this.classList.remove('uc-recording'); }; /** This method is used to toggle recording pause/resume */ _toggleRecording = () => { if (this._mediaRecorder?.state === 'recording') return; if (!this.ref.video.paused && !this.ref.video.ended && this.ref.video.readyState > 2) { this.ref.video.pause(); } else if (this.ref.video.paused) { this.ref.video.play(); } }; _toggleEnableAudio = () => { this._stream?.getAudioTracks().forEach((track) => { track.enabled = !track.enabled; this.$.toggleMicrophoneIcon = !track.enabled ? 'microphone-mute' : 'microphone'; this.$.audioSelectDisabled = !track.enabled; }); }; /** * Previewing the video that was recorded on the camera * * @private */ _previewVideo = () => { try { const blob = new Blob(this._chunks, { type: this._mediaRecorder?.mimeType, }); const videoURL = URL.createObjectURL(blob); this.ref.video.muted = false; this.ref.video.volume = 1; this.$.video = null; this.ref.video.src = videoURL; this.ref.video.addEventListener('play', () => { this._startTimeline(); this.set$({ currentTimelineIcon: 'pause', }); }); this.ref.video.addEventListener('pause', () => { this.set$({ currentTimelineIcon: 'play', }); this._stopTimeline(); }); } catch (error) { console.error('Failed to preview video', error); } }; _retake = () => { this._setCameraState(CameraSourceEvents.RETAKE); /** Reset video */ if (this._activeTab === CameraSourceTypes.VIDEO) { this.$.video = this._stream; this.ref.video.muted = true; } this.ref.video.play(); }; _accept = () => { this._setCameraState(CameraSourceEvents.ACCEPT); if (this._activeTab === CameraSourceTypes.PHOTO) { this._canvas?.toBlob((blob) => { const file = this._createFile('camera', 'jpeg', DEFAULT_PICTURE_FORMAT, blob); this._toSend(file); }, DEFAULT_PICTURE_FORMAT); return; } const blob = new Blob(this._chunks, { type: this._mediaRecorder?.mimeType, }); const ext = this._guessExtensionByMime(this._mediaRecorder?.mimeType); const file = this._createFile('video', ext, `video/${ext}`, blob); this._toSend(file); this._chunks = []; }; /** @param {CameraStatus} status */ _handlePhoto = (status) => { if (status === CameraSourceEvents.SHOT) { this.set$({ tabVideoHidden: true, cameraHidden: true, tabCameraHidden: true, cameraActionsHidden: false, cameraSelectHidden: true, }); } if (status === CameraSourceEvents.RETAKE || status === CameraSourceEvents.ACCEPT) { this.set$({ tabVideoHidden: !this._cameraModes.includes(CameraSourceTypes.VIDEO), tabCameraHidden: !this._cameraModes.includes(CameraSourceTypes.PHOTO), cameraHidden: false, cameraActionsHidden: true, cameraSelectHidden: this._cameraDevices.length <= 1, }); } }; /** @param {CameraStatus} status */ _handleVideo = (status) => { if (status === CameraSourceEvents.PLAY) { this.set$({ timerHidden: false, tabCameraHidden: true, cameraSelectHidden: true, audioSelectHidden: true, currentTimelineIcon: 'pause', currentIcon: 'square', mutableClassButton: 'uc-shot-btn uc-camera-action uc-stop-record', }); } if (status === CameraSourceEvents.STOP) { this.set$({ timerHidden: false, cameraHidden: true, audioToggleMicrophoneHidden: true, cameraActionsHidden: false, }); } if (status === CameraSourceEvents.RETAKE || status === CameraSourceEvents.ACCEPT) { this.set$({ timerHidden: true, tabVideoHidden: !this._cameraModes.includes(CameraSourceTypes.VIDEO), tabCameraHidden: !this._cameraModes.includes(CameraSourceTypes.PHOTO), cameraHidden: false, cameraActionsHidden: true, audioToggleMicrophoneHidden: !this.cfg.enableAudioRecording, currentIcon: 'video-camera-full', mutableClassButton: 'uc-shot-btn uc-camera-action', audioSelectHidden: !this.cfg.enableAudioRecording || this._audioDevices.length <= 1, cameraSelectHidden: this._cameraDevices.length <= 1, }); } }; /** * @private * @param {CameraStatus} status */ _setCameraState = (status) => { if ( this._activeTab === CameraSourceTypes.PHOTO && (status === 'shot' || status === 'retake' || status === 'accept') ) { this._handlePhoto(status); } if ( this._activeTab === CameraSourceTypes.VIDEO && (status === 'play' || status === 'stop' || status === 'retake' || status === 'accept' || status === 'pause' || status === 'resume') ) { this._handleVideo(status); } }; /** @private */ _shot() { this._setCameraState('shot'); this._canvas = document.createElement('canvas'); this._ctx = this._canvas.getContext('2d'); if (!this._ctx) { throw new Error('Failed to get canvas context'); } this._canvas.height = this.ref.video['videoHeight']; this._canvas.width = this.ref.video['videoWidth']; if (this.cfg.cameraMirror) { this._ctx.translate(this._canvas.width, 0); this._ctx.scale(-1, 1); } this._ctx.drawImage(this.ref.video, 0, 0); this.ref.video.pause(); } /** * @private * @param {CameraMode} tabId */ _handleActiveTab = (tabId) => { this.ref.switcher.querySelectorAll('button').forEach((/** @type {HTMLElement} */ btn) => { btn.classList.toggle('uc-active', btn.getAttribute('data-id') === tabId); }); if (tabId === CameraSourceTypes.PHOTO) { this.set$({ currentIcon: 'camera-full', audioSelectHidden: true, audioToggleMicrophoneHidden: true, }); } if (tabId === CameraSourceTypes.VIDEO) { this.set$({ currentTimelineIcon: 'play', currentIcon: 'video-camera-full', audioSelectHidden: !this.cfg.enableAudioRecording || this._audioDevices.length <= 1, audioToggleMicrophoneHidden: !this.cfg.enableAudioRecording, }); } this._activeTab = tabId; }; /** * @param {'camera' | 'video'} type * @param {'jpeg' | 'webm'} ext * @param {String} format * @param {Blob} blob */ _createFile = (type, ext, format, blob) => { const date = Date.now(); const name = `${type}-${date}.${ext}`; const file = new File([blob], name, { lastModified: date, type: format, }); return file; }; /** @param {String | undefined} mime */ _guessExtensionByMime(mime) { const knownContainers = { mp4: 'mp4', ogg: 'ogg', webm: 'webm', quicktime: 'mov', 'x-matroska': 'mkv', }; // MediaRecorder.mimeType returns empty string in Firefox. // Firefox record video as WebM now by default. // @link https://bugzilla.mozilla.org/show_bug.cgi?id=1512175 if (mime === '') { return 'webm'; } // e.g. "video/x-matroska;codecs=avc1,opus" if (mime) { // e.g. ["video", "x-matroska;codecs=avc1,opus"] /** @type {string | string[]} */ (mime) = mime.split('/'); if (mime?.[0] === 'video') { // e.g. "x-matroska;codecs=avc1,opus" mime = mime.slice(1).join('/'); // e.g. "x-matroska" const container = mime?.split(';')[0]; // e.g. "mkv" if (knownContainers[container]) { return knownContainers[container]; } } } // In all other cases just return the base extension for all times return 'avi'; } /** * The send file to the server * * @param {File} file */ _toSend = (file) => { this.api.addFileFromObject(file, { source: UploadSource.CAMERA }); this.set$({ '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, }); this.modalManager.open(ActivityBlock.activities.UPLOAD_LIST); }; /** @private */ get _cameraModes() { return stringToArray(this.cfg.cameraModes); } /** * @private * @param {'granted' | 'denied' | 'prompt'} state */ _setPermissionsState = debounce((state) => { this.classList.toggle('uc-initialized', state === 'granted'); const visibleAudio = this._activeTab === CameraSourceTypes.VIDEO && this.cfg.enableAudioRecording; const currentIcon = this._activeTab === CameraSourceTypes.PHOTO ? 'camera-full' : 'video-camera-full'; if (state === 'granted') { this.set$({ videoHidden: false, cameraHidden: false, tabCameraHidden: !this._cameraModes.includes(CameraSourceTypes.PHOTO), tabVideoHidden: !this._cameraModes.includes(CameraSourceTypes.VIDEO), messageHidden: true, timerHidden: true, currentIcon, audioToggleMicrophoneHidden: !visibleAudio, audioSelectHidden: !visibleAudio, }); } else if (state === 'prompt') { this.$.l10nMessage = 'camera-permissions-prompt'; this.set$({ videoHidden: true, cameraHidden: true, tabCameraHidden: true, messageHidden: false, }); this._stopCapture(); } else { this.$.l10nMessage = 'camera-permissions-denied'; this.set$({ videoHidden: true, messageHidden: false, tabCameraHidden: !this._cameraModes.includes(CameraSourceTypes.PHOTO), tabVideoHidden: !this._cameraModes.includes(CameraSourceTypes.VIDEO), cameraActionsHidden: true, mutableClassButton: 'uc-shot-btn uc-camera-action', }); this._stopCapture(); } }, 300); _makeStreamInactive = () => { if (!this._stream) return false; const audioTracks = this._stream?.getAudioTracks(); const videoTracks = this._stream?.getVideoTracks(); /** @type {MediaStreamTrack[]} */ (audioTracks).forEach((track) => track.stop()); /** @type {MediaStreamTrack[]} */ (videoTracks).forEach((track) => track.stop()); }; _stopCapture = () => { if (this._capturing) { this.ref.video.volume = 0; this.$.video?.getTracks()[0].stop(); this.$.video = null; this._makeStreamInactive(); this._stopTimer(); this._capturing = false; } }; _capture = async () => { const constraints = { video: DEFAULT_VIDEO_CONFIG, audio: this.cfg.enableAudioRecording ? {} : false, }; if (this._selectedCameraId) { constraints.video = { deviceId: { exact: this._selectedCameraId, }, }; } if (this._selectedAudioId && this.cfg.enableAudioRecording) { constraints.audio = { deviceId: { exact: this._selectedAudioId, }, }; } // Mute the video to prevent feedback for Firefox this.ref.video.volume = 0; try { this._setPermissionsState('prompt'); this._stream = await navigator.mediaDevices.getUserMedia(constraints); this._stream.addEventListener('inactive', () => { this._setPermissionsState('denied'); }); this.$.video = this._stream; /** @private */ this._capturing = true; this._setPermissionsState('granted'); } catch (error) { this._setPermissionsState('denied'); console.log('Failed to capture camera', error); } }; _handlePermissionsChange = () => { this._capture(); }; _permissionAccess = async () => { try { for (const permission of DEFAULT_PERMISSIONS) { // @ts-ignore https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API this[`${permission}Response`] = await navigator.permissions.query({ name: permission }); this[`${permission}Response`].addEventListener('change', this._handlePermissionsChange); } } catch (error) { console.log('Failed to use permissions API. Fallback to manual request mode.', error); this._capture(); } }; _getPermission = () => {}; _requestDeviceAccess = async () => { try { await navigator.mediaDevices.getUserMedia({ video: true, audio: this.cfg.enableAudioRecording }); await this._getDevices(); navigator.mediaDevices.addEventListener('devicechange', this._getDevices); } catch (error) { console.log('Failed to get user media', error); } }; _getDevices = async () => { try { const devices = await navigator.mediaDevices.enumerateDevices(); this._cameraDevices = devices .filter((device) => device.kind === 'videoinput') .map((device, index) => ({ text: device.label.trim() || `${this.l10n('caption-camera')} ${index + 1}`, value: device.deviceId, })); this._audioDevices = this.cfg.enableAudioRecording && devices .filter((device) => device.kind === 'audioinput') .map((device) => ({ text: device.label.trim(), value: device.deviceId, })); if (this._cameraDevices.length > 1) { this.set$({ cameraSelectOptions: this._cameraDevices, cameraSelectHidden: false, }); } this._selectedCameraId = this._cameraDevices[0]?.value; if (this._audioDevices.length > 1) { this.set$({ audioSelectOptions: this._audioDevices, audioSelectHidden: false, }); } this._selectedAudioId = this._audioDevices[0]?.value; } catch (error) { console.log('Failed to get devices', error); } }; _onActivate = async () => { await this._permissionAccess(); await this._requestDeviceAccess(); await this._capture(); this._handleCameraModes(this._cameraModes); }; _onDeactivate = async () => { if (this._unsubPermissions) { this._unsubPermissions(); } /** Calling this method here because safari and firefox don't support the inactive event yet */ const isChromium = !!window.chrome; if (!isChromium) { this._setPermissionsState('denied'); } this._stopCapture(); }; /** @param {CameraMode[]} cameraModes */ _handleCameraModes = (cameraModes) => { this.$.tabVideoHidden = !cameraModes.includes(CameraSourceTypes.VIDEO); this.$.tabCameraHidden = !cameraModes.includes(CameraSourceTypes.PHOTO); const defaultTab = cameraModes[0]; if (!this._activeTab || !cameraModes.includes(this._activeTab)) { this._handleActiveTab(defaultTab); } }; initCallback() { super.initCallback(); this.registerActivity(this.activityType, { onActivate: this._onActivate, onDeactivate: this._onDeactivate, }); this.subConfigValue('cameraMirror', (val) => { this.$.videoTransformCss = val ? 'scaleX(-1)' : null; }); this.subConfigValue('enableAudioRecording', (val) => { this.$.audioToggleMicrophoneHidden = !val; this.$.audioSelectDisabled = !val; }); this.subConfigValue('cameraModes', (val) => { if (!this.isActivityActive) return; const cameraModes = deserializeCsv(val); this._handleCameraModes(cameraModes); }); } _destroy() { for (const permission of DEFAULT_PERMISSIONS) { this[`${permission}Response`]?.removeEventListener('change', this._handlePermissionsChange); } navigator.mediaDevices?.removeEventListener('devicechange', this._getDevices); } async destroyCallback() { super.destroyCallback(); this._destroy(); } } CameraSource.template = /* HTML */ ` <uc-activity-header> <button type="button" class="uc-mini-btn" set="onclick: *historyBack" l10n="@title:back"> <uc-icon name="back"></uc-icon> </button> <div set="@hidden: !cameraSelectHidden"> <uc-icon name="camera"></uc-icon> <span l10n="caption-camera"></span> </div> <uc-select class="uc-camera-select" set="$.options: cameraSelectOptions; @hidden: cameraSelectHidden; onchange: onCameraSelectChange" > </uc-select> <button type="button" class="uc-mini-btn uc-close-btn" set="onclick: *closeModal" l10n="@title:a11y-activity-header-button-close;@aria-label:a11y-activity-header-button-close" > <uc-icon name="close"></uc-icon> </button> </uc-activity-header> <div class="uc-content"> <video muted autoplay playsinline set="srcObject: video; style.transform: videoTransformCss; @hidden: videoHidden" ref="video" ></video> <div class="uc-message-box" set="@hidden: messageHidden"> <span l10n="l10nMessage"></span> <button type="button" set="onclick: onRequestPermissions; @hidden: requestBtnHidden" l10n="camera-permissions-request" ></button> </div> </div> <div class="uc-controls"> <div ref="switcher" class="uc-switcher" set="@hidden:!timerHidden"> <button data-id="photo" type="button" class="uc-switch uc-mini-btn" set="onclick: onClickTab; @hidden: tabCameraHidden" > <uc-icon name="camera"></uc-icon> </button> <button data-id="video" type="button" class="uc-switch uc-mini-btn" set="onclick: onClickTab; @hidden: tabVideoHidden" > <uc-icon name="video-camera"></uc-icon> </button> </div> <button class="uc-secondary-btn uc-recording-timer" set="@hidden:timerHidden; onclick: onToggleRecording"> <uc-icon set="@name: currentTimelineIcon"></uc-icon> <span ref="timer"> 00:00 </span> <span ref="line" class="uc-line"></span> </button> <div class="uc-camera-actions uc-camera-action" set="@hidden: cameraActionsHidden"> <button type="button" class="uc-secondary-btn" set="onclick: onRetake">Retake</button> <button type="button" class="uc-primary-btn" set="onclick: onAccept" data-testid="accept">Accept</button> </div> <button type="button" class="uc-shot-btn uc-camera-action" data-testid="shot" set="onclick: onStartCamera; @class: mutableClassButton; @hidden: cameraHidden;" > <uc-icon set="@name: currentIcon"></uc-icon> </button> <div class="uc-select"> <button class="uc-mini-btn uc-btn-microphone" set="onclick: onToggleAudio; @hidden: audioToggleMicrophoneHidden;"> <uc-icon set="@name:toggleMicrophoneIcon"></uc-icon> </button> <uc-select class="uc-audio-select" set="$.options: audioSelectOptions; onchange: onAudioSelectChange; @hidden: audioSelectHidden; @disabled: audioSelectDisabled" > </uc-select> </div> </div> `;