@uploadcare/file-uploader 1.11.0-alpha.0 → 1.11.0-alpha.2

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