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