@es-joy/jsoe 0.6.0 → 0.7.0

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.
@@ -0,0 +1,1148 @@
1
+ import {jml} from '../vendor-imports.js';
2
+ import {$e} from '../utils/templateUtils.js';
3
+ import dialogs from '../utils/dialogs.js';
4
+
5
+ /**
6
+ * @typedef {File|{
7
+ * name: string, size: string, type: string,
8
+ * lastModified: string
9
+ * }} FileInfo
10
+ */
11
+
12
+ /**
13
+ * @typedef {(file: FileInfo) => void} SetValue
14
+ */
15
+
16
+ /** @type {AudioContext} */
17
+ let audioCtx;
18
+ /**
19
+ * @param {MediaStream} stream
20
+ * @param {HTMLCanvasElement} canvas
21
+ * @see Adapted from {@link https://mdn.github.io/dom-examples/media/web-dictaphone/}
22
+ * @returns {void}
23
+ */
24
+ function visualize (stream, canvas) {
25
+ if (!audioCtx) {
26
+ audioCtx = new AudioContext();
27
+ }
28
+ const canvasCtx = /** @type {CanvasRenderingContext2D} */ (
29
+ canvas.getContext('2d')
30
+ );
31
+
32
+ const source = audioCtx.createMediaStreamSource(stream);
33
+
34
+ const analyser = audioCtx.createAnalyser();
35
+ analyser.fftSize = 2048;
36
+ const bufferLength = analyser.frequencyBinCount;
37
+ const dataArray = new Uint8Array(bufferLength);
38
+
39
+ source.connect(analyser);
40
+ // analyser.connect(audioCtx.destination);
41
+
42
+ draw();
43
+
44
+ /**
45
+ * @returns {void}
46
+ */
47
+ function draw () {
48
+ const WIDTH = canvas.width;
49
+ const HEIGHT = canvas.height;
50
+
51
+ requestAnimationFrame(draw);
52
+
53
+ analyser.getByteTimeDomainData(dataArray);
54
+
55
+ canvasCtx.fillStyle = 'rgb(200, 200, 200)';
56
+ canvasCtx.fillRect(0, 0, WIDTH, HEIGHT);
57
+
58
+ canvasCtx.lineWidth = 2;
59
+ canvasCtx.strokeStyle = 'rgb(0, 0, 0)';
60
+
61
+ canvasCtx.beginPath();
62
+
63
+ const sliceWidth = WIDTH * 1 / bufferLength;
64
+ let x = 0;
65
+
66
+ for (let i = 0; i < bufferLength; i++) {
67
+ const v = dataArray[i] / 128;
68
+ const y = v * HEIGHT / 2;
69
+
70
+ if (i === 0) {
71
+ canvasCtx.moveTo(x, y);
72
+ } else {
73
+ canvasCtx.lineTo(x, y);
74
+ }
75
+
76
+ x += sliceWidth;
77
+ }
78
+
79
+ canvasCtx.lineTo(canvas.width, canvas.height / 2);
80
+ canvasCtx.stroke();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @param {MediaStreamConstraints} constraints
86
+ * @returns {Promise<MediaStream|null>}
87
+ */
88
+ async function getUserMedia (constraints) {
89
+ let stream = null;
90
+ try {
91
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
92
+ /* c8 ignore next 4 */
93
+ } catch (err) {
94
+ console.error('err', err);
95
+ return null;
96
+ }
97
+ return stream;
98
+ }
99
+
100
+ /**
101
+ * @param {DisplayMediaStreamOptions|undefined} displayMediaOptions
102
+ * @returns {Promise<MediaStream|null>}
103
+ */
104
+ async function startScreenCapture (displayMediaOptions) {
105
+ let captureStream = null;
106
+
107
+ try {
108
+ captureStream =
109
+ await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
110
+ /* c8 ignore next 3 */
111
+ } catch (err) {
112
+ console.error(`Error: ${err}`);
113
+ }
114
+ return captureStream;
115
+ }
116
+
117
+ /**
118
+ * @param {number} timestamp
119
+ * @returns {string}
120
+ */
121
+ function getDateString (timestamp) {
122
+ const d = new Date(timestamp);
123
+ const dateTimeLocalValue = (
124
+ new Date(
125
+ d.getTime() - (d.getTimezoneOffset() * 60000)
126
+ ).toISOString()
127
+ ).slice(0, -1);
128
+ return dateTimeLocalValue;
129
+ }
130
+
131
+ /**
132
+ * @param {File} value
133
+ * @returns {import('jamilih').JamilihArray}
134
+ */
135
+ function binaryButton (value) {
136
+ // @ts-expect-error It's ok
137
+ return ['button', /** @type {import('jamilih').JamilihAttributes} */ ({
138
+ class: 'viewBinary',
139
+ $custom: {
140
+ $value: value
141
+ },
142
+ $on: {
143
+ /**
144
+ * @this {HTMLButtonElement & {$value: File}}
145
+ * @param {Event} e
146
+ */
147
+ click (e) {
148
+ e.preventDefault();
149
+ if (
150
+ !this.$value ||
151
+ Object.prototype.toString.call(this.$value).slice(8, -1) !== 'File'
152
+ ) {
153
+ dialogs.alert(
154
+ 'There is no file chosen with binary data'
155
+ );
156
+ return;
157
+ }
158
+ const reader = new FileReader();
159
+ reader.addEventListener('load', async function () {
160
+ await dialogs.alert({
161
+ message: ['div', [
162
+ 'Binary source',
163
+ ['br'],
164
+ ['textarea', {class: 'view-binary'}, [
165
+ /* c8 ignore next */
166
+ /** @type {string|null} */ (reader.result) ?? ''
167
+ ]]
168
+ ]]
169
+ });
170
+ });
171
+ // Seems not feasible to accurately simulate
172
+ /* c8 ignore next 10 */
173
+ reader.addEventListener(
174
+ 'error',
175
+ /* c8 ignore next 6 */
176
+ async function () {
177
+ console.error(reader.error);
178
+ await dialogs.alert(/** @type {string} */ (
179
+ /** @type {DOMException} */ (reader.error).message
180
+ ));
181
+ }
182
+ );
183
+ reader.readAsBinaryString(this.$value);
184
+ }
185
+ }
186
+ }), ['View binary data']];
187
+ }
188
+
189
+ /**
190
+ * @type {import('../types.js').TypeObject}
191
+ */
192
+ const fileType = {
193
+ option: ['File'],
194
+ stringRegex: /^File\((.*)\)$/u,
195
+ toValue (s) {
196
+ const obj = JSON.parse(s);
197
+ return {value: new File([obj.stringContents], obj.name, {
198
+ type: obj.type,
199
+ lastModified: obj.lastModified
200
+ })};
201
+ },
202
+ getInput ({root}) {
203
+ return /** @type {HTMLButtonElement} */ ($e(root, 'button.viewBinary'));
204
+ },
205
+ setValue ({root, value}) {
206
+ /** @type {HTMLFieldSetElement & {$setValue: SetValue}} */
207
+ ($e(root, 'fieldset.fileMetaData')).$setValue(value);
208
+ },
209
+ getValue ({root}) {
210
+ // Get value attached to DOM element rather than input,
211
+ // so can be from preexisting file too
212
+ return /** @type {HTMLButtonElement & {$value: File}} */ (
213
+ this.getInput({root})
214
+ ).$value;
215
+ },
216
+ viewUI ({value}) {
217
+ return ['div', {dataset: {type: 'file'}}, [
218
+ ['b', {class: 'emphasis'}, [
219
+ 'File'
220
+ ]],
221
+ ['br'],
222
+ ['br'],
223
+ ['b', [
224
+ 'Name '
225
+ ]],
226
+ value.name,
227
+ ['br'],
228
+ ['b', [
229
+ 'Size (in bytes) '
230
+ ]],
231
+ String(value.size),
232
+ ['br'],
233
+ ['b', [
234
+ 'Content type '
235
+ ]],
236
+ value.type,
237
+ ['br'],
238
+ ['b', [
239
+ 'Last modified date '
240
+ ]],
241
+ getDateString(value.lastModified),
242
+ ['br'],
243
+ value.type.startsWith('text/') || value.type === 'application/json'
244
+ ? (() => {
245
+ const div = /** @type {HTMLDivElement} */ (jml('div'));
246
+ const reader = new FileReader();
247
+ reader.addEventListener('load', function () {
248
+ div.append(jml('div', [
249
+ 'Text source',
250
+ ['br'],
251
+ ['textarea', {class: 'view-text'}, [
252
+ /* c8 ignore next */
253
+ /** @type {string|null} */ (reader.result) ?? ''
254
+ ]]
255
+ ]));
256
+ });
257
+ // Seems not feasible to accurately simulate
258
+ /* c8 ignore next 10 */
259
+ reader.addEventListener(
260
+ 'error',
261
+ /* c8 ignore next 6 */
262
+ function () {
263
+ console.error(reader.error);
264
+ div.append(
265
+ /** @type {DOMException} */ (reader.error).message
266
+ );
267
+ }
268
+ );
269
+ reader.readAsBinaryString(value);
270
+ return div;
271
+ })()
272
+ : binaryButton(value),
273
+ value.type.startsWith('video/')
274
+ ? (() => {
275
+ const objURL = URL.createObjectURL(value);
276
+ const video = /** @type {HTMLVideoElement} */ (jml('video', {
277
+ src: objURL,
278
+ class: 'video',
279
+ $on: {
280
+ loadeddata () {
281
+ URL.revokeObjectURL(objURL);
282
+ }
283
+ }
284
+ }));
285
+
286
+ return ['div', [
287
+ video,
288
+ ['button', {
289
+ class: 'play',
290
+ $on: {
291
+ click (e) {
292
+ e.preventDefault();
293
+ video.play();
294
+ }
295
+ }
296
+ }, [
297
+ 'Play'
298
+ ]],
299
+ ['button', {
300
+ class: 'pause',
301
+ $on: {
302
+ click (e) {
303
+ e.preventDefault();
304
+ video.pause();
305
+ }
306
+ }
307
+ }, [
308
+ 'Pause'
309
+ ]],
310
+ ['button', {
311
+ class: 'replay',
312
+ $on: {
313
+ click (e) {
314
+ e.preventDefault();
315
+ video.pause();
316
+ video.currentTime = 0;
317
+ video.play();
318
+ }
319
+ }
320
+ }, [
321
+ 'Replay'
322
+ ]]
323
+ ]];
324
+ })()
325
+ : '',
326
+ value.type.startsWith('audio/')
327
+ ? (() => {
328
+ const objURL = URL.createObjectURL(value);
329
+ const audio = /** @type {HTMLAudioElement} */ (jml('audio', {
330
+ src: objURL,
331
+ class: 'audio',
332
+ $on: {
333
+ loadeddata () {
334
+ URL.revokeObjectURL(objURL);
335
+ }
336
+ }
337
+ }));
338
+ return ['div', [
339
+ audio,
340
+ ['button', {
341
+ class: 'play',
342
+ $on: {
343
+ click (e) {
344
+ e.preventDefault();
345
+ audio.play();
346
+ }
347
+ }
348
+ }, [
349
+ 'Play'
350
+ ]],
351
+ ['button', {
352
+ class: 'pause',
353
+ $on: {
354
+ click (e) {
355
+ e.preventDefault();
356
+ audio.pause();
357
+ }
358
+ }
359
+ }, [
360
+ 'Pause'
361
+ ]],
362
+ ['button', {
363
+ class: 'replay',
364
+ $on: {
365
+ click (e) {
366
+ e.preventDefault();
367
+ audio.pause();
368
+ audio.currentTime = 0;
369
+ audio.play();
370
+ }
371
+ }
372
+ }, [
373
+ 'Replay'
374
+ ]]
375
+ ]];
376
+ })()
377
+ : '',
378
+ value.type === 'application/pdf'
379
+ ? (() => {
380
+ const objURL = URL.createObjectURL(value);
381
+ const iframe = jml('iframe', {
382
+ class: 'PDF',
383
+ src: objURL,
384
+ $on: {
385
+ load () {
386
+ URL.revokeObjectURL(objURL);
387
+ }
388
+ }
389
+ });
390
+ return iframe;
391
+ })()
392
+ : '',
393
+ value.type.startsWith('image/')
394
+ // @ts-expect-error It's ok
395
+ ? ['img', /** @type {import('jamilih').JamilihAttributes} */ ({
396
+ class: 'imageView',
397
+ src: URL.createObjectURL(value),
398
+ $on: {
399
+ /** @this {HTMLImageElement} */
400
+ load () {
401
+ URL.revokeObjectURL(this.src);
402
+ }
403
+ }
404
+ })]
405
+ : ''
406
+ ]];
407
+ },
408
+ editUI ({typeNamespace, value = {}}) {
409
+ // Todo: Could add way to preview file in edit mode (whether
410
+ // recorded or uploaded)
411
+ return ['div', {dataset: {type: 'file'}}, [
412
+ ['fieldset', {
413
+ class: 'fileMetaData',
414
+ $custom: {
415
+ /**
416
+ * @this {HTMLFieldSetElement}
417
+ * @param {FileInfo} file
418
+ */
419
+ $setValue (file) {
420
+ // eslint-disable-next-line consistent-this -- Clarity
421
+ const metadataFieldset = this;
422
+ /** @type {HTMLInputElement} */ ($e(
423
+ metadataFieldset,
424
+ '.fileName'
425
+ )).value = file.name;
426
+
427
+ /** @type {HTMLInputElement} */ ($e(
428
+ metadataFieldset,
429
+ '.size'
430
+ )).value = String(file.size);
431
+
432
+ /** @type {HTMLInputElement} */ ($e(
433
+ metadataFieldset,
434
+ '.contentType'
435
+ )).value = file.type;
436
+
437
+ /** @type {HTMLInputElement} */ ($e(
438
+ metadataFieldset,
439
+ '.lastModified'
440
+ )).value = typeof file.lastModified === 'number'
441
+ ? getDateString(file.lastModified)
442
+ : '';
443
+
444
+ /** @type {HTMLButtonElement & {$value: File|undefined}} */ ($e(
445
+ metadataFieldset,
446
+ '.viewBinary'
447
+ )).$value = typeof file.lastModified === 'number'
448
+ ? /** @type {File} */ (file)
449
+ : undefined;
450
+ }
451
+ }
452
+ }, [
453
+ ['legend', ['Current file data']],
454
+ ['label', [
455
+ 'Name ',
456
+ ['input', {
457
+ size: 50,
458
+ class: 'fileName', value: value.name ?? '',
459
+ $on: {
460
+ change () {
461
+ const viewBinary =
462
+ /**
463
+ * @type {HTMLButtonElement & {$value: File}}
464
+ */ (
465
+ $e(
466
+ /** @type {HTMLElement} */
467
+ (this.parentElement?.parentElement),
468
+ '.viewBinary'
469
+ )
470
+ );
471
+ const oldFile = /** @type {File|undefined} */ (
472
+ viewBinary.$value
473
+ );
474
+ if (
475
+ !oldFile ||
476
+ Object.prototype.toString.call(
477
+ oldFile
478
+ ).slice(8, -1) !== 'File'
479
+ ) {
480
+ return;
481
+ }
482
+ const newName = /** @type {HTMLInputElement} */ (this).value;
483
+ const file = new File(
484
+ [oldFile],
485
+ newName,
486
+ {
487
+ type: oldFile.type,
488
+ lastModified: oldFile.lastModified
489
+ }
490
+ );
491
+ viewBinary.$value = file;
492
+ }
493
+ }
494
+ }]
495
+ ]],
496
+ ['br'],
497
+ ['label', [
498
+ 'Size (in bytes) ',
499
+ ['input', {
500
+ type: 'number', disabled: true, class: 'size',
501
+ value: String(value.size)
502
+ }]
503
+ ]],
504
+ ['br'],
505
+ ['label', [
506
+ 'Content type ',
507
+ ['input', {
508
+ class: 'contentType', value: value.type ?? '',
509
+ $on: {
510
+ change () {
511
+ const viewBinary =
512
+ /**
513
+ * @type {HTMLButtonElement & {$value: File}}
514
+ */ (
515
+ $e(
516
+ /** @type {HTMLElement} */
517
+ (this.parentElement?.parentElement),
518
+ '.viewBinary'
519
+ )
520
+ );
521
+ const oldFile = /** @type {File|undefined} */ (
522
+ viewBinary.$value
523
+ );
524
+ if (
525
+ !oldFile ||
526
+ Object.prototype.toString.call(
527
+ oldFile
528
+ ).slice(8, -1) !== 'File'
529
+ ) {
530
+ return;
531
+ }
532
+ const newContentType =
533
+ /** @type {HTMLInputElement} */ (this).value;
534
+ const file = new File(
535
+ [oldFile],
536
+ oldFile.name,
537
+ {
538
+ type: newContentType,
539
+ lastModified: oldFile.lastModified
540
+ }
541
+ );
542
+ viewBinary.$value = file;
543
+ }
544
+ }
545
+ }]
546
+ ]],
547
+ ['br'],
548
+ ['label', [
549
+ 'Last modified date ',
550
+ ['input', {
551
+ type: 'datetime-local', disabled: true, class: 'lastModified',
552
+ value: value.lastModified
553
+ ? getDateString(value.lastModified)
554
+ : undefined
555
+ }]
556
+ ]],
557
+ ['br'],
558
+ binaryButton(value),
559
+ ['button', {
560
+ class: 'clearData',
561
+ $on: {
562
+ click (e) {
563
+ e.preventDefault();
564
+ /**
565
+ * @type {HTMLFieldSetElement & {$setValue: SetValue}}
566
+ */ (this.parentElement).$setValue({
567
+ name: '',
568
+ size: '',
569
+ type: '',
570
+ lastModified: ''
571
+ });
572
+ }
573
+ }
574
+ }, [
575
+ 'Clear data'
576
+ ]]
577
+ ]],
578
+ ['fieldset', [
579
+ ['legend', ['Supply file through upload']],
580
+ ['label', [
581
+ 'File ',
582
+ // @ts-expect-error It's ok
583
+ ['input', /** @type {import('jamilih').JamilihAttributes} */ ({
584
+ $on: {
585
+ /**
586
+ * @this {HTMLInputElement}
587
+ */
588
+ change () {
589
+ /* c8 ignore next 3 -- TS */
590
+ if (!this.files) {
591
+ return;
592
+ }
593
+ const file = this.files[0];
594
+
595
+ const metadataFieldset =
596
+ /**
597
+ * @type {HTMLFieldSetElement & {
598
+ * $setValue: SetValue
599
+ * }}
600
+ */ (/** @type {HTMLElement} */ (
601
+ this.parentElement?.parentElement
602
+ )?.previousElementSibling);
603
+ metadataFieldset.$setValue(file);
604
+ }
605
+ },
606
+ name: `${typeNamespace}-file`, type: 'file'
607
+ })]
608
+ ]]
609
+ ]],
610
+ ['fieldset', [
611
+ ['legend', [
612
+ 'Supply file through recording'
613
+ ]],
614
+ (() => {
615
+ const select = /** @type {HTMLSelectElement} */ (
616
+ jml('select', {
617
+ class: 'device',
618
+ $on: {
619
+ async change () {
620
+ const videoContainer = /** @type {HTMLDivElement} */ (
621
+ this.nextElementSibling?.nextElementSibling
622
+ );
623
+
624
+ const photo =
625
+ /**
626
+ * @type {HTMLCanvasElement}
627
+ */
628
+ ($e(videoContainer, 'img.photo'));
629
+ photo.hidden = true;
630
+
631
+ const oldVideo =
632
+ /**
633
+ * @type {HTMLVideoElement & {
634
+ * $stream: MediaStream
635
+ * }}
636
+ */
637
+ ($e(
638
+ videoContainer,
639
+ 'video.previewMedia'
640
+ ));
641
+
642
+ // Drop preexisting listeners
643
+ const previewMedia =
644
+ /**
645
+ * @type {HTMLVideoElement & {
646
+ * $stream: MediaStream,
647
+ * $screenShare: boolean
648
+ * }}
649
+ */ (
650
+ jml('video', {
651
+ class: 'previewMedia'
652
+ })
653
+ );
654
+
655
+ if (oldVideo.$stream) {
656
+ const tracks = /** @type {MediaStream} */ (
657
+ oldVideo.$stream
658
+ ).getTracks();
659
+ tracks.forEach((track) => {
660
+ track.stop();
661
+ });
662
+ oldVideo.srcObject = null;
663
+ }
664
+
665
+ oldVideo.replaceWith(previewMedia);
666
+
667
+ const takeSnapshot = /** @type {HTMLButtonElement} */ (
668
+ $e(/** @type {HTMLDivElement} */ (
669
+ select.nextElementSibling
670
+ ), '.takeSnapshot')
671
+ );
672
+
673
+ const recordMedia = /** @type {HTMLButtonElement} */ (
674
+ $e(/** @type {HTMLDivElement} */ (
675
+ select.nextElementSibling
676
+ ), '.recordMedia')
677
+ );
678
+
679
+ /** @type {HTMLDivElement} */ ($e(
680
+ /** @type {HTMLDivElement} */
681
+ (select.nextElementSibling?.
682
+ nextElementSibling),
683
+ 'div.recordedMedia'
684
+ )).hidden = true;
685
+
686
+ const visualizer = /** @type {HTMLCanvasElement} */ (
687
+ $e(videoContainer, 'canvas.visualizer')
688
+ );
689
+
690
+ /**
691
+ * @type {{
692
+ * video?: true,
693
+ * audio?: true
694
+ * }|undefined}
695
+ */
696
+ let constraints;
697
+ let screenShareConstraints;
698
+
699
+ previewMedia.$screenShare = false;
700
+ switch (select.value) {
701
+ case 'audio-and-video':
702
+ constraints = {video: true, audio: true};
703
+ previewMedia.hidden = false;
704
+ takeSnapshot.hidden = false;
705
+ recordMedia.textContent = 'Record video/audio';
706
+ break;
707
+ case 'video':
708
+ constraints = {video: true};
709
+ previewMedia.hidden = false;
710
+ takeSnapshot.hidden = false;
711
+ recordMedia.textContent = 'Record video';
712
+ break;
713
+ case 'audio':
714
+ constraints = {audio: true};
715
+ previewMedia.hidden = true;
716
+ takeSnapshot.hidden = true;
717
+ recordMedia.textContent = 'Record audio';
718
+ break;
719
+ case 'screenShare':
720
+ screenShareConstraints = {video: true};
721
+ // Fallthrough
722
+ case 'screenShareAndVideo':
723
+ if (!screenShareConstraints) {
724
+ screenShareConstraints = {video: true, audio: true};
725
+ }
726
+ previewMedia.$screenShare = true;
727
+ previewMedia.hidden = true;
728
+ takeSnapshot.hidden = true;
729
+ visualizer.hidden = true;
730
+ break;
731
+ default:
732
+ return;
733
+ }
734
+
735
+ if (constraints) {
736
+ const mediaStream = await getUserMedia(constraints);
737
+ /* c8 ignore next 4 */
738
+ if (!mediaStream) {
739
+ await dialogs.alert('Error getting user media');
740
+ return;
741
+ }
742
+
743
+ videoContainer.hidden = false;
744
+ previewMedia.srcObject = mediaStream;
745
+
746
+ // Save as stream for later reuse as stream
747
+ previewMedia.$stream = mediaStream;
748
+
749
+ if (constraints.video) {
750
+ const canvas =
751
+ /**
752
+ * @type {HTMLCanvasElement}
753
+ */
754
+ ($e(
755
+ videoContainer,
756
+ 'canvas.recordedImage'
757
+ ));
758
+ // https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API/Taking_still_photos
759
+ previewMedia.addEventListener('canplay', () => {
760
+ canvas.setAttribute('width', String(
761
+ previewMedia.videoWidth
762
+ ));
763
+ canvas.setAttribute(
764
+ 'height', String(previewMedia.videoHeight)
765
+ );
766
+ });
767
+ }
768
+
769
+ previewMedia.addEventListener('loadedmetadata', () => {
770
+ previewMedia.play();
771
+ if (constraints?.audio) {
772
+ visualizer.hidden = false;
773
+ visualize(
774
+ mediaStream,
775
+ visualizer
776
+ );
777
+ } else {
778
+ visualizer.hidden = true;
779
+ }
780
+ });
781
+ } else if (screenShareConstraints) {
782
+ const mediaStream = await startScreenCapture(
783
+ screenShareConstraints
784
+ );
785
+ /* c8 ignore next 4 */
786
+ if (!mediaStream) {
787
+ await dialogs.alert('Error getting user media');
788
+ return;
789
+ }
790
+
791
+ videoContainer.hidden = false;
792
+ previewMedia.srcObject = mediaStream;
793
+
794
+ // Save as stream for later reuse as stream
795
+ previewMedia.$stream = mediaStream;
796
+ }
797
+ }
798
+ }
799
+ }, [
800
+ ['option', {value: ''}, ['Please choose a device']]
801
+ ])
802
+ );
803
+
804
+ (async () => {
805
+ const devices = await navigator.mediaDevices.enumerateDevices();
806
+ let hasAudioInput = false;
807
+ let hasVideoInput = false;
808
+ devices.forEach((device) => {
809
+ switch (device.kind) {
810
+ case 'audioinput':
811
+ hasAudioInput = true;
812
+ break;
813
+ case 'videoinput':
814
+ hasVideoInput = true;
815
+ break;
816
+ default:
817
+ break;
818
+ }
819
+ });
820
+
821
+ if (hasAudioInput) {
822
+ jml('option', {value: 'audio'}, ['Microphone only'], select);
823
+ }
824
+ if (hasVideoInput) {
825
+ jml('option', {value: 'video'}, ['Video camera only'], select);
826
+ }
827
+ if (hasAudioInput && hasVideoInput) {
828
+ jml(
829
+ 'option',
830
+ {value: 'audio-and-video'},
831
+ ['Video camera and microphone'],
832
+ select
833
+ );
834
+ }
835
+
836
+ // Can't be feature detected for privacy reasons
837
+ jml('option', {
838
+ value: 'screenShare'
839
+ }, ['Screen share'], select);
840
+ jml('option', {
841
+ value: 'screenShareAndVideo'
842
+ }, ['Screen share and microphone'], select);
843
+ })();
844
+
845
+ return select;
846
+ })(),
847
+ ' ',
848
+ ['div', [
849
+ // @ts-expect-error It's ok
850
+ ['button', /** @type {import('jamilih').JamilihAttributes} */ ({
851
+ class: 'recordMedia',
852
+ $on: {
853
+ /**
854
+ * @param {Event} e
855
+ * @this {HTMLButtonElement & {
856
+ * $mediaRecorder: MediaRecorder
857
+ * }}
858
+ */
859
+ click (e) {
860
+ e.preventDefault();
861
+ const previewMedia =
862
+ /**
863
+ * @type {HTMLVideoElement & {$stream: MediaStream}}
864
+ */ ($e(
865
+ /** @type {HTMLDivElement} */
866
+ (this.parentElement?.nextElementSibling),
867
+ 'video.previewMedia'
868
+ ));
869
+
870
+ /* c8 ignore next 4 */
871
+ if (!previewMedia.srcObject) {
872
+ dialogs.alert('No stream found to record');
873
+ return;
874
+ }
875
+
876
+ // Not interested in the photo now, so hide it
877
+ const videoContainer = /** @type {HTMLDivElement} */ (
878
+ this.parentElement?.nextElementSibling
879
+ );
880
+ const photo =
881
+ /**
882
+ * @type {HTMLCanvasElement}
883
+ */
884
+ ($e(videoContainer, 'img.photo'));
885
+ photo.hidden = true;
886
+
887
+ // Todo: Could check codecs for allowable values
888
+ // as second argument
889
+ // see https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/isTypeSupported_static
890
+ const mimeType = 'video/webm';
891
+ const mediaRecorder = new MediaRecorder(previewMedia.$stream, {
892
+ mimeType
893
+ });
894
+
895
+ /** @type {Blob[]} */
896
+ let chunks = [];
897
+ mediaRecorder.addEventListener('dataavailable', (e) => {
898
+ chunks.push(e.data);
899
+ });
900
+
901
+ try {
902
+ mediaRecorder.start();
903
+ /* c8 ignore next 4 */
904
+ } catch (err) {
905
+ dialogs.alert('Error starting media recorder');
906
+ return;
907
+ }
908
+ mediaRecorder.addEventListener('stop', () => {
909
+ const blob = new Blob(chunks, {
910
+ type: mimeType
911
+ });
912
+
913
+ const url = URL.createObjectURL(blob);
914
+
915
+ const recordedMedia = /** @type {HTMLVideoElement} */ ($e(
916
+ /** @type {HTMLDivElement} */
917
+ (this.parentElement?.nextElementSibling),
918
+ 'video.recordedMedia'
919
+ // )).srcObject = blob;
920
+ ));
921
+ /** @type {HTMLDivElement} */ (
922
+ recordedMedia.parentElement
923
+ ).hidden = false;
924
+
925
+ recordedMedia.src = url;
926
+
927
+ recordedMedia.addEventListener('loadeddata', () => {
928
+ URL.revokeObjectURL(url);
929
+ });
930
+
931
+ const file = new File(
932
+ [blob],
933
+ 'placeholder.webm',
934
+ {type: mimeType}
935
+ );
936
+ console.log('file', file);
937
+
938
+ const root = /** @type {HTMLDivElement} */
939
+ (this.closest('[data-type="file"]'));
940
+ /**
941
+ * @type {HTMLFieldSetElement & {
942
+ * $setValue: SetValue
943
+ * }}
944
+ */ ($e(
945
+ root, 'fieldset.fileMetaData'
946
+ )).$setValue(file);
947
+
948
+ chunks = [];
949
+ });
950
+
951
+ this.$mediaRecorder = mediaRecorder;
952
+
953
+ // Todo: Could make option for front/back
954
+ // camera ("user"/"environment") and bringIntoFocus
955
+ }
956
+ }
957
+ }), [
958
+ 'Record media'
959
+ ]],
960
+ ['button', {
961
+ class: 'stopRecording',
962
+ $on: {
963
+ click (e) {
964
+ e.preventDefault();
965
+ /**
966
+ * @type {HTMLButtonElement & {$mediaRecorder: MediaRecorder}}
967
+ */ (
968
+ this.previousElementSibling
969
+ ).$mediaRecorder.stop();
970
+
971
+ // const previewMedia =
972
+ // /**
973
+ // * @type {HTMLVideoElement & {
974
+ // * $stream: MediaStream,
975
+ // * $screenShare: boolean
976
+ // * }}
977
+ // */ (
978
+ // $e(
979
+ // /** @type {HTMLDivElement} */
980
+ // (this.parentElement?.nextElementSibling),
981
+ // '.previewMedia'
982
+ // )
983
+ // );
984
+
985
+ // Doesn't hurt to keep the normal video going as user
986
+ // may wish to re-record, but for screen-sharing, we
987
+ // may want to reenable this, at the cost that the
988
+ // screen sharing cannot be re-done
989
+ // if (previewMedia.$screenShare) {
990
+ // const tracks = /** @type {MediaStream} */ (
991
+ // previewMedia.$stream
992
+ // ).getTracks();
993
+ // tracks.forEach((track) => {
994
+ // track.stop();
995
+ // });
996
+ // }
997
+ // previewMedia.srcObject = null;
998
+ }
999
+ }
1000
+ }, [
1001
+ 'Stop recording'
1002
+ ]],
1003
+ ['button', {
1004
+ class: 'takeSnapshot',
1005
+ hidden: true
1006
+ }, {
1007
+ $on: {
1008
+ click (e) {
1009
+ e.preventDefault();
1010
+
1011
+ const videoContainer = /** @type {HTMLDivElement} */
1012
+ (this.parentElement?.nextElementSibling);
1013
+
1014
+ // Not interested in this anymore
1015
+ /** @type {HTMLVideoElement} */
1016
+ ($e(videoContainer, '.recordedMedia')).hidden = true;
1017
+
1018
+ const canvas =
1019
+ /**
1020
+ * @type {HTMLCanvasElement}
1021
+ */
1022
+ ($e(videoContainer, 'canvas.recordedImage'));
1023
+
1024
+ const context = /** @type {CanvasRenderingContext2D} */ (
1025
+ canvas.getContext('2d')
1026
+ );
1027
+
1028
+ const previewMedia =
1029
+ /**
1030
+ * @type {HTMLVideoElement}
1031
+ */
1032
+ ($e(videoContainer, 'video.previewMedia'));
1033
+ context.drawImage(
1034
+ previewMedia, 0, 0, canvas.width, canvas.height
1035
+ );
1036
+ const photo =
1037
+ /**
1038
+ * @type {HTMLCanvasElement}
1039
+ */
1040
+ ($e(videoContainer, 'img.photo'));
1041
+
1042
+ canvas.toBlob((blob) => {
1043
+ /* c8 ignore next 4 */
1044
+ if (!blob) {
1045
+ dialogs.alert('Error converting canvas to Blob');
1046
+ return;
1047
+ }
1048
+ const newPhoto = /** @type {HTMLImageElement} */ (jml('img', {
1049
+ class: 'photo'
1050
+ }));
1051
+ const url = URL.createObjectURL(blob);
1052
+
1053
+ newPhoto.addEventListener('load', () => {
1054
+ // no longer need to read the blob so it's revoked
1055
+ URL.revokeObjectURL(url);
1056
+ });
1057
+
1058
+ const file = new File(
1059
+ [blob],
1060
+ 'placeholder.png',
1061
+ {type: blob.type}
1062
+ );
1063
+ console.log('file', file);
1064
+
1065
+ const root = /** @type {HTMLDivElement} */
1066
+ (this.closest('[data-type="file"]'));
1067
+ /**
1068
+ * @type {HTMLFieldSetElement & {
1069
+ * $setValue: SetValue
1070
+ * }}
1071
+ */ ($e(
1072
+ root, 'fieldset.fileMetaData'
1073
+ )).$setValue(file);
1074
+
1075
+ newPhoto.src = url;
1076
+ photo.replaceWith(newPhoto);
1077
+ });
1078
+ }
1079
+ }
1080
+ }, [
1081
+ 'Take and use snapshot as file'
1082
+ ]]
1083
+ ]],
1084
+ ['div', {class: 'videoContainer', hidden: true}, [
1085
+ ['canvas', {class: 'visualizer', hidden: true}],
1086
+ ['br'],
1087
+ ['video', {class: 'previewMedia'}],
1088
+ ['div', [
1089
+ ['canvas', {hidden: true, class: 'recordedImage'}],
1090
+ ['img', {class: 'photo'}]
1091
+ ]],
1092
+ [
1093
+ 'div',
1094
+ {class: 'recordedMedia'},
1095
+ /** @type {import('jamilih').JamilihChildren} */ ([
1096
+ ...(() => {
1097
+ const recordedMedia = /** @type {HTMLVideoElement} */ (jml(
1098
+ 'video', {class: 'recordedMedia'}
1099
+ ));
1100
+ return [
1101
+ recordedMedia,
1102
+ ['br'],
1103
+ jml('button', {
1104
+ class: 'play',
1105
+ $on: {
1106
+ click (e) {
1107
+ e.preventDefault();
1108
+ recordedMedia.play();
1109
+ }
1110
+ }
1111
+ }, [
1112
+ 'Play'
1113
+ ]),
1114
+ jml('button', {
1115
+ class: 'pause',
1116
+ $on: {
1117
+ click (e) {
1118
+ e.preventDefault();
1119
+ recordedMedia.pause();
1120
+ }
1121
+ }
1122
+ }, [
1123
+ 'Pause'
1124
+ ]),
1125
+ jml('button', {
1126
+ class: 'replay',
1127
+ $on: {
1128
+ click (e) {
1129
+ e.preventDefault();
1130
+ recordedMedia.pause();
1131
+ recordedMedia.currentTime = 0;
1132
+ recordedMedia.play();
1133
+ }
1134
+ }
1135
+ }, [
1136
+ 'Replay'
1137
+ ])
1138
+ ];
1139
+ })()
1140
+ ])
1141
+ ]
1142
+ ]]
1143
+ ]]
1144
+ ]];
1145
+ }
1146
+ };
1147
+
1148
+ export default fileType;