@es-joy/jsoe 0.8.0 → 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.
- package/.nyc_output/out.json +11255 -6080
- package/CHANGES.md +9 -0
- package/README.md +3 -2
- package/demo/index.js +16 -3
- package/package.json +1 -1
- package/src/formats/structuredCloning.js +9 -11
- package/src/fundamentalTypes/arrayType.js +4 -1
- package/src/fundamentalTypes/blobType.js +1001 -0
- package/src/fundamentalTypes/fileType.js +1 -101
- package/src/jsoe.css +4 -0
- package/src/types.js +3 -1
- package/src/utils/media.js +103 -0
|
@@ -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;
|