@uploadcare/file-uploader 1.11.0-alpha.3 → 1.11.0-alpha.4

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