@uploadcare/file-uploader 1.10.0 → 1.11.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/blocks/CameraSource/CameraSource.d.ts +169 -22
  2. package/blocks/CameraSource/CameraSource.d.ts.map +1 -1
  3. package/blocks/CameraSource/CameraSource.js +803 -126
  4. package/blocks/CameraSource/camera-source.css +90 -3
  5. package/blocks/Config/Config.d.ts +4 -2
  6. package/blocks/Config/Config.d.ts.map +1 -1
  7. package/blocks/Config/Config.js +2 -0
  8. package/blocks/Config/initialConfig.d.ts.map +1 -1
  9. package/blocks/Config/initialConfig.js +6 -0
  10. package/blocks/Config/normalizeConfigValue.d.ts.map +1 -1
  11. package/blocks/Config/normalizeConfigValue.js +17 -1
  12. package/blocks/LocalEditorImage/LocalEditorImage.d.ts +22 -0
  13. package/blocks/LocalEditorImage/LocalEditorImage.d.ts.map +1 -0
  14. package/blocks/LocalEditorImage/LocalEditorImage.js +139 -0
  15. package/blocks/LocalEditorImage/localEditorImage.css +19 -0
  16. package/blocks/themes/uc-basic/common.css +5 -0
  17. package/blocks/themes/uc-basic/svg-sprite.d.ts +1 -1
  18. package/blocks/themes/uc-basic/svg-sprite.js +1 -1
  19. package/blocks/themes/uc-basic/theme.css +2 -1
  20. package/index.ssr.d.ts +14 -0
  21. package/index.ssr.d.ts.map +1 -1
  22. package/index.ssr.js +71 -2
  23. package/package.json +4 -2
  24. package/types/exported.d.ts +21 -0
  25. package/web/file-uploader.iife.min.js +4 -4
  26. package/web/file-uploader.min.js +4 -4
  27. package/web/uc-basic.min.css +1 -1
  28. package/web/uc-cloud-image-editor.min.css +1 -1
  29. package/web/uc-cloud-image-editor.min.js +4 -4
  30. package/web/uc-file-uploader-inline.min.css +1 -1
  31. package/web/uc-file-uploader-inline.min.js +4 -4
  32. package/web/uc-file-uploader-minimal.min.css +1 -1
  33. package/web/uc-file-uploader-minimal.min.js +3 -3
  34. package/web/uc-file-uploader-regular.min.css +1 -1
  35. package/web/uc-file-uploader-regular.min.js +4 -4
@@ -1,9 +1,45 @@
1
- import { UploaderBlock } from '../../abstract/UploaderBlock.js';
1
+ //@ts-nocheck
2
2
  import { ActivityBlock } from '../../abstract/ActivityBlock.js';
3
+ import { UploaderBlock } from '../../abstract/UploaderBlock.js';
3
4
  import { canUsePermissionsApi } from '../utils/abilities.js';
4
5
  import { debounce } from '../utils/debounce.js';
5
6
  import { UploadSource } from '../utils/UploadSource.js';
6
7
 
8
+ const DEFAULT_VIDEO_CONFIG = {
9
+ width: {
10
+ ideal: 1920,
11
+ },
12
+ height: {
13
+ ideal: 1080,
14
+ },
15
+ frameRate: {
16
+ ideal: 30,
17
+ },
18
+ };
19
+
20
+ const DEFAULT_PERMISSIONS = ['camera', 'microphone'];
21
+
22
+ /**
23
+ * @param {Number} time
24
+ * @returns
25
+ */
26
+ function formatTime(time) {
27
+ const minutes = Math.floor(time / 60)
28
+ .toString()
29
+ .padStart(2, '0');
30
+ const seconds = Math.floor(time % 60)
31
+ .toString()
32
+ .padStart(2, '0');
33
+ return `${minutes}:${seconds}`;
34
+ }
35
+
36
+ const DEFAULT_PICTURE_FORMAT = 'image/jpeg';
37
+ const DEFAULT_VIDEO_FORMAT = 'video/webm';
38
+
39
+ /** @typedef {'photo' | 'video'} CameraTabId */
40
+
41
+ /** @typedef {'shot' | 'retake' | 'accept' | 'play' | 'stop' | 'pause' | 'resume'} CameraStatus */
42
+
7
43
  export class CameraSource extends UploaderBlock {
8
44
  couldBeCtxOwner = true;
9
45
  activityType = ActivityBlock.activities.CAMERA;
@@ -11,54 +47,526 @@ export class CameraSource extends UploaderBlock {
11
47
  /** @private */
12
48
  _unsubPermissions = null;
13
49
 
14
- init$ = {
15
- ...this.init$,
16
- video: null,
17
- videoTransformCss: null,
18
- shotBtnDisabled: true,
19
- videoHidden: true,
20
- messageHidden: true,
21
- requestBtnHidden: canUsePermissionsApi(),
22
- cameraSelectOptions: null,
23
- cameraSelectHidden: true,
24
- l10nMessage: '',
25
-
26
- onCameraSelectChange: (e) => {
27
- /** @type {String} */
28
- this._selectedCameraId = e.target.value;
29
- this._capture();
30
- },
31
- onCancel: () => {
32
- this.historyBack();
33
- },
34
- onShot: () => {
50
+ /** @type {BlobPart[]} */
51
+ _chunks = [];
52
+
53
+ /** @type {MediaRecorder | null} */
54
+ _mediaRecorder = null;
55
+
56
+ /** @type {MediaStream | null} */
57
+ _stream = null;
58
+
59
+ /** @type {string | null} */
60
+ _selectedAudioId = null;
61
+
62
+ /** @type {string | null} */
63
+ _selectedCameraId = null;
64
+
65
+ constructor() {
66
+ super();
67
+
68
+ this.init$ = {
69
+ ...this.init$,
70
+ video: null,
71
+ videoTransformCss: null,
72
+
73
+ videoHidden: true,
74
+ messageHidden: true,
75
+ requestBtnHidden: canUsePermissionsApi(),
76
+ cameraSelectOptions: null,
77
+ cameraSelectHidden: true,
78
+ l10nMessage: '',
79
+
80
+ // This is refs
81
+ switcher: null,
82
+ panels: null,
83
+ timer: null,
84
+
85
+ timerHidden: true,
86
+ cameraHidden: true,
87
+ cameraActionsHidden: true,
88
+
89
+ audioSelectOptions: null,
90
+ audioSelectHidden: true,
91
+ audioSelectDisabled: true,
92
+ audioToggleMicorphoneHidden: true,
93
+
94
+ tabCameraHidden: true,
95
+ tabVideoHidden: true,
96
+
97
+ currentIcon: 'camera-full',
98
+ currentTimelineIcon: 'play',
99
+ toggleMicorphoneIcon: 'microphone',
100
+
101
+ /** @type {Number} */
102
+ _startTime: 0,
103
+ /** @type {Number} */
104
+ _elapsedTime: 0,
105
+ _animationFrameId: null,
106
+
107
+ mutableClassButton: 'uc-shot-btn uc-camera-action',
108
+
109
+ /** @param {Event} e */
110
+ onCameraSelectChange: (e) => {
111
+ this._selectedCameraId = e.target.value;
112
+ this._capture();
113
+ },
114
+
115
+ /** @param {Event} e */
116
+ onAudioSelectChange: (e) => {
117
+ this._selectedAudioId = e.target.value;
118
+ this._capture();
119
+ },
120
+
121
+ onCancel: () => {
122
+ this.historyBack();
123
+ },
124
+
125
+ onShot: () => this._shot(),
126
+
127
+ onRequestPermissions: () => this._capture(),
128
+
129
+ /** General method for photo and video capture */
130
+ onStartCamera: () => this._chooseActionWithCamera(),
131
+
132
+ onStartRecording: () => this._startRecording(),
133
+
134
+ onStopRecording: () => this._stopRecording(),
135
+
136
+ onToggleRecording: () => this._toggleRecording(),
137
+
138
+ onToggleAudio: () => this._toggleEnableAudio(),
139
+
140
+ onRetake: () => this._retake(),
141
+
142
+ onAccept: () => this._accept(),
143
+
144
+ /** @param {MouseEvent} e */
145
+ onClickTab: (e) => {
146
+ const id = /** @type {HTMLElement} */ (e.currentTarget).getAttribute('data-id');
147
+ if (id) this._handleActiveTab(/** @type {CameraTabId} */ (id));
148
+ },
149
+ };
150
+ }
151
+
152
+ _chooseActionWithCamera = () => {
153
+ if (this._activeTab === CameraSource.types.PHOTO) {
35
154
  this._shot();
36
- },
37
- onRequestPermissions: () => {
38
- this._capture();
39
- },
155
+ }
156
+
157
+ if (this._activeTab === CameraSource.types.VIDEO) {
158
+ if (this._mediaRecorder?.state === 'recording') {
159
+ this._stopRecording();
160
+ return;
161
+ }
162
+
163
+ this._startRecording();
164
+ }
40
165
  };
41
166
 
42
- /** @private */
43
- _onActivate = () => {
44
- if (canUsePermissionsApi()) {
45
- this._subscribePermissions();
167
+ _updateTimer = () => {
168
+ const currentTime = Math.floor((performance.now() - this.$._startTime + this.$._elapsedTime) / 1000);
169
+
170
+ if (typeof this.cfg.maxDurationVideoRecord === 'number' && this.cfg.maxDurationVideoRecord > 0) {
171
+ const remainingTime = this.cfg.maxDurationVideoRecord - currentTime;
172
+
173
+ if (remainingTime <= 0) {
174
+ this.ref.timer.textContent = formatTime(remainingTime);
175
+ this._stopRecording();
176
+ return;
177
+ }
178
+
179
+ this.ref.timer.textContent = formatTime(remainingTime);
180
+ } else {
181
+ this.ref.timer.textContent = formatTime(currentTime);
182
+ }
183
+
184
+ this._animationFrameId = requestAnimationFrame(this._updateTimer);
185
+ };
186
+
187
+ _startTimer = () => {
188
+ this.$._startTime = performance.now();
189
+ this.$._elapsedTime = 0;
190
+
191
+ this._updateTimer();
192
+ };
193
+
194
+ _stopTimer = () => {
195
+ if (this._animationFrameId) cancelAnimationFrame(this._animationFrameId);
196
+ };
197
+
198
+ _startTimeline = () => {
199
+ const currentTime = this.ref.video.currentTime;
200
+ const duration = this.ref.video.duration;
201
+
202
+ this.ref.line.style.transform = `scaleX(${currentTime / duration})`;
203
+ this.ref.timer.textContent = formatTime(currentTime);
204
+ this._animationFrameId = requestAnimationFrame(this._startTimeline);
205
+ };
206
+
207
+ _stopTimeline = () => {
208
+ if (this._animationFrameId) cancelAnimationFrame(this._animationFrameId);
209
+ };
210
+
211
+ _startRecording = () => {
212
+ try {
213
+ this._chunks = [];
214
+ this._options = {
215
+ ...this.cfg.optionsMediaRecorder,
216
+ };
217
+
218
+ const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
219
+
220
+ if (
221
+ this.cfg.optionsMediaRecorder?.mimeType &&
222
+ MediaRecorder.isTypeSupported(this.cfg.optionsMediaRecorder.mimeType)
223
+ ) {
224
+ this._options.mimeType = this.cfg.optionsMediaRecorder.mimeType;
225
+ } else if (
226
+ isFirefox &&
227
+ this.cfg.optionsMediaRecorder?.mimeType &&
228
+ MediaRecorder.isTypeSupported(this.cfg.optionsMediaRecorder.mimeType)
229
+ ) {
230
+ const mimeType = this.cfg.optionsMediaRecorder?.mimeType;
231
+
232
+ if (mimeType && !MediaRecorder.isTypeSupported(mimeType)) {
233
+ throw new Error(`MIME type ${mimeType} is not supported`);
234
+ }
235
+
236
+ this._options.mimeType = DEFAULT_VIDEO_FORMAT;
237
+ }
238
+
239
+ if (this._stream) {
240
+ this._mediaRecorder = new MediaRecorder(this._stream, this._options);
241
+ this._mediaRecorder.start();
242
+
243
+ this._mediaRecorder.addEventListener('dataavailable', (e) => {
244
+ this._chunks.push(e.data);
245
+ });
246
+
247
+ this._startTimer();
248
+
249
+ this.classList.add('uc-recording');
250
+ this._setCameraState(CameraSource.events.PLAY);
251
+ }
252
+ } catch (error) {
253
+ console.error('Failed to start recording', error);
46
254
  }
47
- this._capture();
48
255
  };
49
256
 
50
257
  /** @private */
51
- _onDeactivate = () => {
52
- if (this._unsubPermissions) {
53
- this._unsubPermissions();
258
+ _stopRecording = () => {
259
+ this._mediaRecorder?.addEventListener('stop', () => {
260
+ this._previewVideo();
261
+
262
+ this._stopTimer();
263
+
264
+ this._setCameraState(CameraSource.events.STOP);
265
+ });
266
+
267
+ this._mediaRecorder?.stop();
268
+ this.classList.remove('uc-recording');
269
+ };
270
+
271
+ /** This method is used to toggle recording pause/resume */
272
+ _toggleRecording = () => {
273
+ if (this._mediaRecorder?.state === 'recording') return;
274
+
275
+ if (!this.ref.video.paused && !this.ref.video.ended && this.ref.video.readyState > 2) {
276
+ this.set$({
277
+ currentTimelineIcon: 'play',
278
+ });
279
+
280
+ this.ref.video.pause();
281
+ } else if (this.ref.video.paused) {
282
+ this.set$({
283
+ currentTimelineIcon: 'pause',
284
+ });
285
+
286
+ this.ref.video.play();
54
287
  }
288
+ };
55
289
 
56
- this._stopCapture();
290
+ _toggleEnableAudio = () => {
291
+ this._stream?.getAudioTracks().forEach((track) => {
292
+ track.enabled = !track.enabled;
293
+
294
+ this.$.toggleMicorphoneIcon = !track.enabled ? 'microphone-mute' : 'microphone';
295
+ this.$.audioSelectDisabled = !track.enabled;
296
+ });
297
+ };
298
+
299
+ /**
300
+ * Previewing the video that was recorded on the camera
301
+ *
302
+ * @private
303
+ */
304
+ _previewVideo = () => {
305
+ try {
306
+ const blob = new Blob(this._chunks, {
307
+ type: this._mediaRecorder?.mimeType,
308
+ });
309
+
310
+ const videoURL = URL.createObjectURL(blob);
311
+
312
+ this.ref.video.muted = false;
313
+ this.ref.video.volume = 1;
314
+ this.$.video = null;
315
+ this.ref.video.src = videoURL;
316
+
317
+ this.ref.video.addEventListener('play', () => {
318
+ this._startTimeline();
319
+ });
320
+
321
+ this.ref.video.addEventListener('pause', () => {
322
+ this._stopTimeline();
323
+ });
324
+ } catch (error) {
325
+ console.error('Failed to preview video', error);
326
+ }
327
+ };
328
+
329
+ _retake = () => {
330
+ this._setCameraState(CameraSource.events.RETAKE);
331
+
332
+ /** Reset video */
333
+ if (this._activeTab === CameraSource.types.VIDEO) {
334
+ this.$.video = this._stream;
335
+ this.ref.video.muted = true;
336
+ }
337
+
338
+ this.ref.video.play();
339
+ };
340
+
341
+ _accept = () => {
342
+ this._setCameraState(CameraSource.events.ACCEPT);
343
+
344
+ if (this._activeTab === CameraSource.types.PHOTO) {
345
+ this._canvas?.toBlob((blob) => {
346
+ const file = this._createFile('camera', 'jpeg', DEFAULT_PICTURE_FORMAT, blob);
347
+ this._toSend(file);
348
+ }, DEFAULT_PICTURE_FORMAT);
349
+ return;
350
+ }
351
+
352
+ const blob = new Blob(this._chunks, {
353
+ type: this._mediaRecorder?.mimeType,
354
+ });
355
+
356
+ const ext = this._guessExtensionByMime(this._mediaRecorder?.mimeType);
357
+ const file = this._createFile('video', ext, `video/${ext}`, blob);
358
+
359
+ this._toSend(file);
360
+ this._chunks = [];
361
+ };
362
+
363
+ /** @param {CameraStatus} status */
364
+ _handlePhoto = (status) => {
365
+ if (status === CameraSource.events.SHOT) {
366
+ this.set$({
367
+ tabVideoHidden: true,
368
+ cameraHidden: true,
369
+ tabCameraHidden: true,
370
+ cameraActionsHidden: false,
371
+ cameraSelectHidden: true,
372
+ });
373
+ }
374
+
375
+ if (status === CameraSource.events.RETAKE || status === CameraSource.events.ACCEPT) {
376
+ this.set$({
377
+ tabVideoHidden: !this.cfg.enableVideoRecording,
378
+ cameraHidden: false,
379
+ tabCameraHidden: false,
380
+ cameraActionsHidden: true,
381
+ cameraSelectHidden: this.cameraDevices.length <= 1,
382
+ });
383
+ }
384
+ };
385
+
386
+ /** @param {CameraStatus} status */
387
+ _handleVideo = (status) => {
388
+ if (status === CameraSource.events.PLAY) {
389
+ this.set$({
390
+ timerHidden: false,
391
+ tabCameraHidden: true,
392
+
393
+ cameraSelectHidden: true,
394
+ audioSelectHidden: true,
395
+
396
+ currentTimelineIcon: 'pause',
397
+ currentIcon: 'square',
398
+ mutableClassButton: 'uc-shot-btn uc-camera-action uc-stop-record',
399
+ });
400
+ }
401
+
402
+ if (status === CameraSource.events.STOP) {
403
+ this.set$({
404
+ timerHidden: false,
405
+ cameraHidden: true,
406
+ audioToggleMicorphoneHidden: true,
407
+ cameraActionsHidden: false,
408
+ });
409
+ }
410
+
411
+ if (status === CameraSource.events.RETAKE || status === CameraSource.events.ACCEPT) {
412
+ this.set$({
413
+ timerHidden: true,
414
+ tabCameraHidden: false,
415
+ cameraHidden: false,
416
+ cameraActionsHidden: true,
417
+ audioToggleMicorphoneHidden: !this.cfg.enableAudioRecording,
418
+ currentIcon: 'video-camera-full',
419
+ mutableClassButton: 'uc-shot-btn uc-camera-action',
420
+
421
+ audioSelectHidden: !this.cfg.enableAudioRecording,
422
+ cameraSelectHidden: this.cameraDevices.length <= 1,
423
+ });
424
+ }
425
+ };
426
+
427
+ /**
428
+ * @private
429
+ * @param {CameraStatus} status
430
+ */
431
+ _setCameraState = (status) => {
432
+ if (
433
+ this._activeTab === CameraSource.types.PHOTO &&
434
+ (status === 'shot' || status === 'retake' || status === 'accept')
435
+ ) {
436
+ this._handlePhoto(status);
437
+ }
438
+
439
+ if (
440
+ this._activeTab === CameraSource.types.VIDEO &&
441
+ (status === 'play' ||
442
+ status === 'stop' ||
443
+ status === 'retake' ||
444
+ status === 'accept' ||
445
+ status === 'pause' ||
446
+ status === 'resume')
447
+ ) {
448
+ this._handleVideo(status);
449
+ }
57
450
  };
58
451
 
59
452
  /** @private */
60
- _handlePermissionsChange = () => {
61
- this._capture();
453
+ _shot() {
454
+ this._setCameraState('shot');
455
+
456
+ this._canvas = document.createElement('canvas');
457
+ this._ctx = this._canvas.getContext('2d');
458
+
459
+ if (!this._ctx) {
460
+ throw new Error('Failed to get canvas context');
461
+ }
462
+
463
+ this._canvas.height = this.ref.video['videoHeight'];
464
+ this._canvas.width = this.ref.video['videoWidth'];
465
+
466
+ if (this.cfg.cameraMirror) {
467
+ this._ctx.translate(this._canvas.width, 0);
468
+ this._ctx.scale(-1, 1);
469
+ }
470
+
471
+ this._ctx.drawImage(this.ref.video, 0, 0);
472
+ this.ref.video.pause();
473
+ }
474
+
475
+ /**
476
+ * @private
477
+ * @param {CameraTabId} tabId
478
+ */
479
+ _handleActiveTab = (tabId) => {
480
+ this.ref.switcher.querySelectorAll('button').forEach((/** @type {HTMLElement} */ btn) => {
481
+ btn.classList.toggle('uc-active', btn.getAttribute('data-id') === tabId);
482
+ });
483
+
484
+ if (tabId === CameraSource.types.PHOTO) {
485
+ this.set$({
486
+ currentIcon: 'camera-full',
487
+ audioSelectHidden: true,
488
+ audioToggleMicorphoneHidden: true,
489
+ });
490
+ }
491
+
492
+ if (tabId === CameraSource.types.VIDEO) {
493
+ this.set$({
494
+ currentTimelineIcon: 'play',
495
+ currentIcon: 'video-camera-full',
496
+
497
+ audioSelectHidden: !this.cfg.enableAudioRecording,
498
+ audioToggleMicorphoneHidden: !this.cfg.enableAudioRecording,
499
+ });
500
+ }
501
+
502
+ this._activeTab = tabId;
503
+ };
504
+
505
+ /**
506
+ * @param {'camera' | 'video'} type
507
+ * @param {'jpeg' | 'webm'} ext
508
+ * @param {String} format
509
+ * @param {Blob} blob
510
+ */
511
+ _createFile = (type, ext, format, blob) => {
512
+ const date = Date.now();
513
+ const name = `${type}-${date}.${ext}`;
514
+
515
+ const file = new File([blob], name, {
516
+ lastModified: date,
517
+ type: format,
518
+ });
519
+
520
+ return file;
521
+ };
522
+
523
+ /** @param {String | undefined} mime */
524
+ _guessExtensionByMime(mime) {
525
+ const knownContainers = {
526
+ mp4: 'mp4',
527
+ ogg: 'ogg',
528
+ webm: 'webm',
529
+ quicktime: 'mov',
530
+ 'x-matroska': 'mkv',
531
+ };
532
+
533
+ // MediaRecorder.mimeType returns empty string in Firefox.
534
+ // Firefox record video as WebM now by default.
535
+ // @link https://bugzilla.mozilla.org/show_bug.cgi?id=1512175
536
+ if (mime === '') {
537
+ return 'webm';
538
+ }
539
+
540
+ // e.g. "video/x-matroska;codecs=avc1,opus"
541
+ if (mime) {
542
+ // e.g. ["video", "x-matroska;codecs=avc1,opus"]
543
+ /** @type {string | string[]} */ (mime) = mime.split('/');
544
+ if (mime?.[0] === 'video') {
545
+ // e.g. "x-matroska;codecs=avc1,opus"
546
+ mime = mime.slice(1).join('/');
547
+ // e.g. "x-matroska"
548
+ const container = mime?.split(';')[0];
549
+ // e.g. "mkv"
550
+ if (knownContainers[container]) {
551
+ return knownContainers[container];
552
+ }
553
+ }
554
+ }
555
+
556
+ // In all other cases just return the base extension for all times
557
+ return 'avi';
558
+ }
559
+
560
+ /**
561
+ * The send file to the server
562
+ *
563
+ * @param {File} file
564
+ */
565
+ _toSend = (file) => {
566
+ this.api.addFileFromObject(file, { source: UploadSource.CAMERA });
567
+ this.set$({
568
+ '*currentActivity': ActivityBlock.activities.UPLOAD_LIST,
569
+ });
62
570
  };
63
571
 
64
572
  /**
@@ -68,117 +576,208 @@ export class CameraSource extends UploaderBlock {
68
576
  _setPermissionsState = debounce((state) => {
69
577
  this.classList.toggle('uc-initialized', state === 'granted');
70
578
 
579
+ const visibleAudio = this._activeTab === CameraSource.types.VIDEO && this.cfg.enableAudioRecording;
580
+ const currentIcon = this._activeTab === CameraSource.types.PHOTO ? 'camera-full' : 'video-camera-full';
581
+
71
582
  if (state === 'granted') {
72
583
  this.set$({
73
584
  videoHidden: false,
74
- shotBtnDisabled: false,
585
+ cameraHidden: false,
586
+ tabCameraHidden: false,
75
587
  messageHidden: true,
588
+ timerHidden: true,
589
+
590
+ currentIcon,
591
+ audioToggleMicorphoneHidden: !visibleAudio,
592
+ audioSelectHidden: !visibleAudio,
76
593
  });
77
594
  } else if (state === 'prompt') {
78
595
  this.$.l10nMessage = 'camera-permissions-prompt';
596
+
79
597
  this.set$({
80
598
  videoHidden: true,
81
- shotBtnDisabled: true,
599
+ cameraHidden: true,
600
+ tabCameraHidden: true,
82
601
  messageHidden: false,
83
602
  });
603
+
84
604
  this._stopCapture();
85
605
  } else {
86
606
  this.$.l10nMessage = 'camera-permissions-denied';
87
607
 
88
608
  this.set$({
89
609
  videoHidden: true,
90
- shotBtnDisabled: true,
91
610
  messageHidden: false,
611
+
612
+ tabCameraHidden: false,
613
+ tabVideoHidden: !this.cfg.enableVideoRecording,
614
+
615
+ cameraActionsHidden: true,
616
+
617
+ mutableClassButton: 'uc-shot-btn uc-camera-action',
92
618
  });
619
+
93
620
  this._stopCapture();
94
621
  }
95
622
  }, 300);
96
623
 
97
- /** @private */
98
- async _subscribePermissions() {
99
- try {
100
- // @ts-ignore
101
- let permissionsResponse = await navigator.permissions.query({ name: 'camera' });
102
- permissionsResponse.addEventListener('change', this._handlePermissionsChange);
103
- } catch (err) {
104
- console.log('Failed to use permissions API. Fallback to manual request mode.', err);
105
- this._capture();
624
+ _makeStreamInactive = () => {
625
+ if (!this._stream) return false;
626
+
627
+ const audioTracks = this._stream?.getAudioTracks();
628
+ const videoTracks = this._stream?.getVideoTracks();
629
+
630
+ /** @type {MediaStreamTrack[]} */ (audioTracks).forEach((track) => track.stop());
631
+ /** @type {MediaStreamTrack[]} */ (videoTracks).forEach((track) => track.stop());
632
+ };
633
+
634
+ _stopCapture = () => {
635
+ if (this._capturing) {
636
+ this.ref.video.volume = 0;
637
+ this.$.video?.getTracks()[0].stop();
638
+ this.$.video = null;
639
+
640
+ this._makeStreamInactive();
641
+
642
+ this._capturing = false;
106
643
  }
107
- }
644
+ };
108
645
 
109
- /** @private */
110
- async _capture() {
111
- let constr = {
112
- video: {
113
- width: {
114
- ideal: 1920,
115
- },
116
- height: {
117
- ideal: 1080,
118
- },
119
- frameRate: {
120
- ideal: 30,
121
- },
122
- },
123
- audio: false,
646
+ _capture = async () => {
647
+ const constraints = {
648
+ video: DEFAULT_VIDEO_CONFIG,
649
+ audio: this.cfg.enableAudioRecording ? {} : false,
124
650
  };
651
+
125
652
  if (this._selectedCameraId) {
126
- constr.video.deviceId = {
127
- exact: this._selectedCameraId,
653
+ constraints.video = {
654
+ deviceId: {
655
+ exact: this._selectedCameraId,
656
+ },
128
657
  };
129
658
  }
130
- /** @private */
131
- this._canvas = document.createElement('canvas');
132
- /** @private */
133
- this._ctx = this._canvas.getContext('2d');
659
+
660
+ if (this._selectedAudioId && this.cfg.enableAudioRecording) {
661
+ constraints.audio = {
662
+ deviceId: {
663
+ exact: this._selectedAudioId,
664
+ },
665
+ };
666
+ }
667
+
668
+ // Mute the video to prevent feedback for Firefox
669
+ this.ref.video.volume = 0;
134
670
 
135
671
  try {
136
672
  this._setPermissionsState('prompt');
137
- let stream = await navigator.mediaDevices.getUserMedia(constr);
138
- stream.addEventListener('inactive', () => {
673
+ this._stream = await navigator.mediaDevices.getUserMedia(constraints);
674
+
675
+ this._stream.addEventListener('inactive', () => {
139
676
  this._setPermissionsState('denied');
140
677
  });
141
- this.$.video = stream;
678
+
679
+ this.$.video = this._stream;
142
680
  /** @private */
143
681
  this._capturing = true;
144
682
  this._setPermissionsState('granted');
145
- } catch (err) {
683
+ } catch (error) {
146
684
  this._setPermissionsState('denied');
147
- console.error('Failed to capture camera', err);
685
+ console.log('Failed to capture camera', error);
148
686
  }
149
- }
687
+ };
150
688
 
151
- /** @private */
152
- _stopCapture() {
153
- if (this._capturing) {
154
- this.$.video?.getTracks()[0].stop();
155
- this.$.video = null;
156
- this._capturing = false;
689
+ _handlePermissionsChange = () => {
690
+ this._capture();
691
+ };
692
+
693
+ _permissionAccess = async () => {
694
+ try {
695
+ for (const permission of DEFAULT_PERMISSIONS) {
696
+ // @ts-ignore https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API
697
+ this[`${permission}Response`] = await navigator.permissions.query({ name: permission });
698
+
699
+ this[`${permission}Response`].addEventListener('change', this._handlePermissionsChange);
700
+ }
701
+ } catch (error) {
702
+ console.log('Failed to use permissions API. Fallback to manual request mode.', error);
703
+ this._capture();
157
704
  }
158
- }
705
+ };
159
706
 
160
- /** @private */
161
- _shot() {
162
- this._canvas.height = this.ref.video['videoHeight'];
163
- this._canvas.width = this.ref.video['videoWidth'];
164
- // @ts-ignore
165
- this._ctx.drawImage(this.ref.video, 0, 0);
166
- const date = Date.now();
167
- const name = `camera-${date}.jpeg`;
168
- const format = 'image/jpeg';
169
- this._canvas.toBlob((blob) => {
170
- let file = new File([blob], name, {
171
- lastModified: date,
172
- type: format,
173
- });
174
- this.api.addFileFromObject(file, { source: UploadSource.CAMERA });
175
- this.set$({
176
- '*currentActivity': ActivityBlock.activities.UPLOAD_LIST,
177
- });
178
- }, format);
179
- }
707
+ _getPermission = () => {};
708
+
709
+ _requestDeviceAccess = async () => {
710
+ try {
711
+ await navigator.mediaDevices.getUserMedia({ video: true, audio: this.cfg.enableAudioRecording });
712
+ await this._getDevices();
713
+
714
+ navigator.mediaDevices.addEventListener('devicechange', this._getDevices);
715
+ } catch (error) {
716
+ console.log('Failed to get user media', error);
717
+ }
718
+ };
719
+
720
+ _getDevices = async () => {
721
+ try {
722
+ const devices = await navigator.mediaDevices.enumerateDevices();
723
+
724
+ this.cameraDevices = devices
725
+ .filter((device) => device.kind === 'videoinput')
726
+ .map((device, index) => ({
727
+ text: device.label.trim() || `${this.l10n('caption-camera')} ${index + 1}`,
728
+ value: device.deviceId,
729
+ }));
730
+
731
+ this.audioDevices =
732
+ this.cfg.enableAudioRecording &&
733
+ devices
734
+ .filter((device) => device.kind === 'audioinput')
735
+ .map((device) => ({
736
+ text: device.label.trim(),
737
+ value: device.deviceId,
738
+ }));
739
+
740
+ if (this.cameraDevices.length > 1) {
741
+ this.set$({
742
+ cameraSelectOptions: this.cameraDevices,
743
+ cameraSelectHidden: false,
744
+ });
745
+ }
746
+ this._selectedCameraId = this.cameraDevices[0]?.value;
747
+
748
+ if (this.audioDevices.length > 1) {
749
+ this.set$({
750
+ audioSelectOptions: this.audioDevices,
751
+ audioSelectHidden: false,
752
+ });
753
+ }
754
+ this._selectedAudioId = this.audioDevices[0]?.value;
755
+ } catch (error) {
756
+ console.log('Failed to get devices', error);
757
+ }
758
+ };
759
+
760
+ _onActivate = async () => {
761
+ await this._permissionAccess();
762
+ await this._requestDeviceAccess();
763
+ await this._capture();
764
+ };
765
+
766
+ _onDeactivate = async () => {
767
+ if (this._unsubPermissions) {
768
+ this._unsubPermissions();
769
+ }
180
770
 
181
- async initCallback() {
771
+ /** Calling this method here because safari and firefox don't support the inactive event yet */
772
+ const isChromium = !!window.chrome;
773
+ if (!isChromium) {
774
+ this._setPermissionsState('denied');
775
+ }
776
+
777
+ this._stopCapture();
778
+ };
779
+
780
+ initCallback() {
182
781
  super.initCallback();
183
782
  this.registerActivity(this.activityType, {
184
783
  onActivate: this._onActivate,
@@ -189,30 +788,58 @@ export class CameraSource extends UploaderBlock {
189
788
  this.$.videoTransformCss = val ? 'scaleX(-1)' : null;
190
789
  });
191
790
 
192
- try {
193
- let deviceList = await navigator.mediaDevices.enumerateDevices();
194
- let cameraSelectOptions = deviceList
195
- .filter((info) => {
196
- return info.kind === 'videoinput';
197
- })
198
- .map((info, idx) => {
199
- return {
200
- text: info.label.trim() || `${this.l10n('caption-camera')} ${idx + 1}`,
201
- value: info.deviceId,
202
- };
203
- });
204
- if (cameraSelectOptions.length > 1) {
205
- this.$.cameraSelectOptions = cameraSelectOptions;
206
- this.$.cameraSelectHidden = false;
791
+ this.subConfigValue('enableAudioRecording', (val) => {
792
+ this.$.audioToggleMicorphoneHidden = !val;
793
+ this.$.audioSelectDisabled = !val;
794
+ });
795
+
796
+ this.subConfigValue('enableVideoRecording', (val) => {
797
+ this.$.tabVideoHidden = !val;
798
+ });
799
+
800
+ this.subConfigValue('defaultCameraTab', (val) => {
801
+ if (this.cfg.enableVideoRecording) {
802
+ this._handleActiveTab(val);
803
+ return;
207
804
  }
208
- this._selectedCameraId = cameraSelectOptions[0]?.value;
209
- } catch (err) {
210
- // mediaDevices isn't available for HTTP
211
- // TODO: handle this case
805
+
806
+ this._handleActiveTab('photo');
807
+ });
808
+ }
809
+
810
+ _destroy() {
811
+ for (const permission of DEFAULT_PERMISSIONS) {
812
+ this[`${permission}Response`].removeEventListener('change', this._handlePermissionsChange);
212
813
  }
814
+
815
+ navigator.mediaDevices.removeEventListener('devicechange', this._getDevices);
816
+ }
817
+
818
+ async destroyCallback() {
819
+ super.destroyCallback();
820
+
821
+ this._destroy();
213
822
  }
214
823
  }
215
824
 
825
+ CameraSource.types = Object.freeze({
826
+ PHOTO: 'photo',
827
+ VIDEO: 'video',
828
+ });
829
+
830
+ CameraSource.events = Object.freeze({
831
+ IDLE: 'idle',
832
+ SHOT: 'shot',
833
+
834
+ PLAY: 'play',
835
+ PAUSE: 'pause',
836
+ RESUME: 'resume',
837
+ STOP: 'stop',
838
+
839
+ RETAKE: 'retake',
840
+ ACCEPT: 'accept',
841
+ });
842
+
216
843
  CameraSource.template = /* HTML */ `
217
844
  <uc-activity-header>
218
845
  <button type="button" class="uc-mini-btn" set="onclick: *historyBack" l10n="@title:back">
@@ -238,6 +865,7 @@ CameraSource.template = /* HTML */ `
238
865
  </uc-activity-header>
239
866
  <div class="uc-content">
240
867
  <video
868
+ muted
241
869
  autoplay
242
870
  playsinline
243
871
  set="srcObject: video; style.transform: videoTransformCss; @hidden: videoHidden"
@@ -251,8 +879,57 @@ CameraSource.template = /* HTML */ `
251
879
  l10n="camera-permissions-request"
252
880
  ></button>
253
881
  </div>
254
- <button type="button" class="uc-shot-btn" set="onclick: onShot; @disabled: shotBtnDisabled">
255
- <uc-icon name="camera"></uc-icon>
882
+ </div>
883
+
884
+ <div class="uc-controls">
885
+ <div ref="switcher" class="uc-switcher" set="@hidden:!timerHidden">
886
+ <button
887
+ data-id="photo"
888
+ type="button"
889
+ class="uc-switch uc-mini-btn"
890
+ set="onclick: onClickTab; @hidden: tabCameraHidden"
891
+ >
892
+ <uc-icon name="camera"></uc-icon>
893
+ </button>
894
+ <button
895
+ data-id="video"
896
+ type="button"
897
+ class="uc-switch uc-mini-btn"
898
+ set="onclick: onClickTab; @hidden: tabVideoHidden"
899
+ >
900
+ <uc-icon name="video-camera"></uc-icon>
901
+ </button>
902
+ </div>
903
+
904
+ <button class="uc-secondary-btn uc-recording-timer" set="@hidden:timerHidden; onclick: onToggleRecording">
905
+ <uc-icon set="@name: currentTimelineIcon"></uc-icon>
906
+ <span ref="timer"> 00:00 </span>
907
+ <span ref="line" class="uc-line"></span>
256
908
  </button>
909
+
910
+ <div class="uc-camera-actions uc-camera-action" set="@hidden: cameraActionsHidden">
911
+ <button type="button" class="uc-secondary-btn" set="onclick: onRetake">Retake</button>
912
+ <button type="button" class="uc-primary-btn" set="onclick: onAccept">Accept</button>
913
+ </div>
914
+
915
+ <button
916
+ type="button"
917
+ class="uc-shot-btn uc-camera-action"
918
+ set="onclick: onStartCamera; @class: mutableClassButton; @hidden: cameraHidden;"
919
+ >
920
+ <uc-icon set="@name: currentIcon"></uc-icon>
921
+ </button>
922
+
923
+ <div class="uc-select">
924
+ <button class="uc-mini-btn uc-btn-microphone" set="onclick: onToggleAudio; @hidden: audioToggleMicorphoneHidden;">
925
+ <uc-icon set="@name:toggleMicorphoneIcon"></uc-icon>
926
+ </button>
927
+
928
+ <uc-select
929
+ class="uc-audio-select"
930
+ set="$.options: audioSelectOptions; onchange: onAudioSelectChange; @hidden: audioSelectHidden; @disabled: audioSelectDisabled"
931
+ >
932
+ </uc-select>
933
+ </div>
257
934
  </div>
258
935
  `;