@uploadcare/file-uploader 1.11.0-alpha.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 (71) 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 +805 -126
  4. package/blocks/CameraSource/camera-source.css +105 -7
  5. package/blocks/CloudImageEditorActivity/index.css +3 -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 +1 -1
  10. package/blocks/Config/initialConfig.d.ts.map +1 -1
  11. package/blocks/Config/initialConfig.js +7 -1
  12. package/blocks/Config/normalizeConfigValue.d.ts.map +1 -1
  13. package/blocks/Config/normalizeConfigValue.js +17 -1
  14. package/blocks/ExternalSource/ExternalSource.d.ts +50 -20
  15. package/blocks/ExternalSource/ExternalSource.d.ts.map +1 -1
  16. package/blocks/ExternalSource/ExternalSource.js +99 -96
  17. package/blocks/ExternalSource/buildStyles.d.ts +27 -0
  18. package/blocks/ExternalSource/buildStyles.d.ts.map +1 -0
  19. package/blocks/ExternalSource/buildStyles.js +133 -0
  20. package/blocks/ExternalSource/external-source.css +22 -37
  21. package/blocks/ExternalSource/messages.d.ts +3 -0
  22. package/blocks/ExternalSource/messages.d.ts.map +1 -0
  23. package/blocks/ExternalSource/messages.js +35 -0
  24. package/blocks/LocalEditorImage/LocalEditorImage.d.ts +22 -0
  25. package/blocks/LocalEditorImage/LocalEditorImage.d.ts.map +1 -0
  26. package/blocks/LocalEditorImage/LocalEditorImage.js +139 -0
  27. package/blocks/LocalEditorImage/localEditorImage.css +19 -0
  28. package/blocks/Modal/Modal.d.ts.map +1 -1
  29. package/blocks/Modal/Modal.js +11 -4
  30. package/blocks/Modal/modal.css +54 -3
  31. package/blocks/StartFrom/start-from.css +6 -2
  32. package/blocks/UploadList/upload-list.css +11 -1
  33. package/blocks/UrlSource/url-source.css +8 -0
  34. package/blocks/themes/uc-basic/common.css +5 -0
  35. package/blocks/themes/uc-basic/svg-sprite.d.ts +1 -1
  36. package/blocks/themes/uc-basic/svg-sprite.js +1 -1
  37. package/blocks/themes/uc-basic/theme.css +3 -2
  38. package/env.d.ts +1 -1
  39. package/env.d.ts.map +1 -1
  40. package/env.js +1 -1
  41. package/index.ssr.d.ts +15 -1
  42. package/index.ssr.d.ts.map +1 -1
  43. package/index.ssr.js +82 -9
  44. package/locales/file-uploader/en.d.ts +0 -2
  45. package/locales/file-uploader/en.js +1 -3
  46. package/locales/file-uploader/fi.d.ts +120 -0
  47. package/locales/file-uploader/fi.d.ts.map +1 -0
  48. package/locales/file-uploader/fi.js +120 -0
  49. package/package.json +1 -1
  50. package/types/exported.d.ts +21 -0
  51. package/web/file-uploader.iife.min.js +4 -4
  52. package/web/file-uploader.min.js +4 -4
  53. package/web/uc-basic.min.css +1 -1
  54. package/web/uc-cloud-image-editor.min.css +1 -1
  55. package/web/uc-cloud-image-editor.min.js +4 -4
  56. package/web/uc-file-uploader-inline.min.css +1 -1
  57. package/web/uc-file-uploader-inline.min.js +4 -4
  58. package/web/uc-file-uploader-minimal.min.css +1 -1
  59. package/web/uc-file-uploader-minimal.min.js +3 -3
  60. package/web/uc-file-uploader-regular.min.css +1 -1
  61. package/web/uc-file-uploader-regular.min.js +4 -4
  62. package/web/uc-img.min.js +1 -1
  63. package/blocks/ExternalSource/MessageBridge.d.ts +0 -20
  64. package/blocks/ExternalSource/MessageBridge.d.ts.map +0 -1
  65. package/blocks/ExternalSource/MessageBridge.js +0 -71
  66. package/blocks/ExternalSource/buildThemeDefinition.d.ts +0 -3
  67. package/blocks/ExternalSource/buildThemeDefinition.d.ts.map +0 -1
  68. package/blocks/ExternalSource/buildThemeDefinition.js +0 -47
  69. package/blocks/ExternalSource/types.d.ts +0 -113
  70. package/blocks/ExternalSource/types.d.ts.map +0 -1
  71. package/blocks/ExternalSource/types.js +0 -143
@@ -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
  /**
@@ -66,117 +574,210 @@ export class CameraSource extends UploaderBlock {
66
574
  * @param {'granted' | 'denied' | 'prompt'} state
67
575
  */
68
576
  _setPermissionsState = debounce((state) => {
577
+ this.classList.toggle('uc-initialized', state === 'granted');
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
+
69
582
  if (state === 'granted') {
70
583
  this.set$({
71
584
  videoHidden: false,
72
- shotBtnDisabled: false,
585
+ cameraHidden: false,
586
+ tabCameraHidden: false,
73
587
  messageHidden: true,
588
+ timerHidden: true,
589
+
590
+ currentIcon,
591
+ audioToggleMicorphoneHidden: !visibleAudio,
592
+ audioSelectHidden: !visibleAudio,
74
593
  });
75
594
  } else if (state === 'prompt') {
76
595
  this.$.l10nMessage = 'camera-permissions-prompt';
596
+
77
597
  this.set$({
78
598
  videoHidden: true,
79
- shotBtnDisabled: true,
599
+ cameraHidden: true,
600
+ tabCameraHidden: true,
80
601
  messageHidden: false,
81
602
  });
603
+
82
604
  this._stopCapture();
83
605
  } else {
84
606
  this.$.l10nMessage = 'camera-permissions-denied';
85
607
 
86
608
  this.set$({
87
609
  videoHidden: true,
88
- shotBtnDisabled: true,
89
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',
90
618
  });
619
+
91
620
  this._stopCapture();
92
621
  }
93
622
  }, 300);
94
623
 
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();
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;
104
643
  }
105
- }
644
+ };
106
645
 
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,
646
+ _capture = async () => {
647
+ const constraints = {
648
+ video: DEFAULT_VIDEO_CONFIG,
649
+ audio: this.cfg.enableAudioRecording ? {} : false,
122
650
  };
651
+
123
652
  if (this._selectedCameraId) {
124
- constr.video.deviceId = {
125
- exact: this._selectedCameraId,
653
+ constraints.video = {
654
+ deviceId: {
655
+ exact: this._selectedCameraId,
656
+ },
126
657
  };
127
658
  }
128
- /** @private */
129
- this._canvas = document.createElement('canvas');
130
- /** @private */
131
- 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;
132
670
 
133
671
  try {
134
672
  this._setPermissionsState('prompt');
135
- let stream = await navigator.mediaDevices.getUserMedia(constr);
136
- stream.addEventListener('inactive', () => {
673
+ this._stream = await navigator.mediaDevices.getUserMedia(constraints);
674
+
675
+ this._stream.addEventListener('inactive', () => {
137
676
  this._setPermissionsState('denied');
138
677
  });
139
- this.$.video = stream;
678
+
679
+ this.$.video = this._stream;
140
680
  /** @private */
141
681
  this._capturing = true;
142
682
  this._setPermissionsState('granted');
143
- } catch (err) {
683
+ } catch (error) {
144
684
  this._setPermissionsState('denied');
145
- console.error('Failed to capture camera', err);
685
+ console.log('Failed to capture camera', error);
146
686
  }
147
- }
687
+ };
148
688
 
149
- /** @private */
150
- _stopCapture() {
151
- if (this._capturing) {
152
- this.$.video?.getTracks()[0].stop();
153
- this.$.video = null;
154
- 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();
155
704
  }
156
- }
705
+ };
157
706
 
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
- }
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
+ }
178
770
 
179
- 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() {
180
781
  super.initCallback();
181
782
  this.registerActivity(this.activityType, {
182
783
  onActivate: this._onActivate,
@@ -187,30 +788,58 @@ export class CameraSource extends UploaderBlock {
187
788
  this.$.videoTransformCss = val ? 'scaleX(-1)' : null;
188
789
  });
189
790
 
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;
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;
205
804
  }
206
- this._selectedCameraId = cameraSelectOptions[0]?.value;
207
- } catch (err) {
208
- // mediaDevices isn't available for HTTP
209
- // 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);
210
813
  }
814
+
815
+ navigator.mediaDevices.removeEventListener('devicechange', this._getDevices);
816
+ }
817
+
818
+ async destroyCallback() {
819
+ super.destroyCallback();
820
+
821
+ this._destroy();
211
822
  }
212
823
  }
213
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
+
214
843
  CameraSource.template = /* HTML */ `
215
844
  <uc-activity-header>
216
845
  <button type="button" class="uc-mini-btn" set="onclick: *historyBack" l10n="@title:back">
@@ -236,6 +865,7 @@ CameraSource.template = /* HTML */ `
236
865
  </uc-activity-header>
237
866
  <div class="uc-content">
238
867
  <video
868
+ muted
239
869
  autoplay
240
870
  playsinline
241
871
  set="srcObject: video; style.transform: videoTransformCss; @hidden: videoHidden"
@@ -249,8 +879,57 @@ CameraSource.template = /* HTML */ `
249
879
  l10n="camera-permissions-request"
250
880
  ></button>
251
881
  </div>
252
- <button type="button" class="uc-shot-btn" set="onclick: onShot; @disabled: shotBtnDisabled">
253
- <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>
254
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>
255
934
  </div>
256
935
  `;