presently 0.23.0 → 0.24.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.
data/public/recorder.js DELETED
@@ -1,366 +0,0 @@
1
- const RECORDING_EDGE_DELAY = 100;
2
-
3
- // Records and reviews the narration for one slide. The element is recreated
4
- // when the selected slide changes, which gives each recording session a clear
5
- // lifetime and releases the microphone during navigation.
6
- export class PresentlyRecorder extends HTMLElement {
7
- #mediaRecorder = null;
8
- #mediaStream = null;
9
- #audioContext = null;
10
- #audioSource = null;
11
- #audioDelay = null;
12
- #audioDestination = null;
13
- #chunks = [];
14
- #recording = null;
15
- #recordingURL = null;
16
- #recordingDuration = null;
17
- #startedAt = null;
18
- #timer = null;
19
- #startToken = null;
20
- #ignoreClick = false;
21
-
22
- connectedCallback() {
23
- this.recordButton = this.querySelector('.recording-toggle');
24
- this.saveButton = this.querySelector('.recording-save');
25
- this.playback = this.querySelector('.recording-playback');
26
- this.applyDurationButton = this.querySelector('.recording-apply-duration');
27
- this.updateDuration = this.querySelector('.recording-update-duration');
28
- this.status = this.querySelector('.recording-status');
29
- this.time = this.querySelector('.recording-time');
30
-
31
- // Stop as soon as the pointer is pressed so the delayed audio excludes the
32
- // click. The click handler provides both starting and keyboard activation.
33
- this.recordButton.addEventListener('pointerdown', () => {
34
- if (this.isRecording()) {
35
- this.#ignoreClick = true;
36
- this.stop();
37
- }
38
- });
39
- this.recordButton.addEventListener('click', () => {
40
- if (this.#ignoreClick) {
41
- this.#ignoreClick = false;
42
- return;
43
- }
44
-
45
- if (this.recordButton.disabled) return;
46
-
47
- if (this.isRecording()) {
48
- this.stop();
49
- } else {
50
- this.start(performance.now());
51
- }
52
- });
53
- this.saveButton.addEventListener('click', () => this.save());
54
- this.applyDurationButton.addEventListener('click', () => this.updateSlideDuration());
55
- this.playback.addEventListener('loadedmetadata', () => this.updateDurationAction());
56
-
57
- this.loadExisting();
58
- }
59
-
60
- disconnectedCallback() {
61
- this.#startToken = null;
62
-
63
- if (this.#mediaRecorder?.state === 'recording') {
64
- this.#mediaRecorder.onstop = null;
65
- this.#mediaRecorder.stop();
66
- }
67
-
68
- this.releaseMicrophone();
69
- this.stopTimer();
70
- this.releaseRecordingURL();
71
- }
72
-
73
- get url() {
74
- return this.dataset.recordingUrl;
75
- }
76
-
77
- isRecording() {
78
- return this.#mediaRecorder?.state === 'recording';
79
- }
80
-
81
- async loadExisting() {
82
- try {
83
- const response = await fetch(this.url, {method: 'HEAD', cache: 'no-store'});
84
-
85
- if (response.ok) {
86
- this.playback.src = this.cacheBustedURL();
87
- this.playback.hidden = false;
88
- this.recordButton.textContent = '● Retake';
89
- this.setStatus('Existing recording loaded.');
90
- } else if (response.status === 404) {
91
- this.setStatus('No recording yet.');
92
- } else {
93
- throw new Error(`Could not check recording (${response.status}).`);
94
- }
95
- } catch (error) {
96
- this.setStatus(error.message, true);
97
- }
98
- }
99
-
100
- async start(clickedAt) {
101
- const AudioContext = window.AudioContext || window.webkitAudioContext;
102
- if (!window.MediaRecorder || !navigator.mediaDevices?.getUserMedia || !AudioContext) {
103
- this.setStatus('Audio recording is not supported by this browser.', true);
104
- return;
105
- }
106
-
107
- const mimeType = 'audio/webm;codecs=opus';
108
- if (!MediaRecorder.isTypeSupported(mimeType)) {
109
- this.setStatus(`${mimeType} recording is not supported by this browser.`, true);
110
- return;
111
- }
112
-
113
- const startToken = this.#startToken = {};
114
- this.recordButton.disabled = true;
115
- this.saveButton.disabled = true;
116
- this.applyDurationButton.hidden = true;
117
- this.#recordingDuration = null;
118
- this.dataset.state = 'preparing';
119
- this.setStatus('Preparing microphone…');
120
-
121
- try {
122
- this.playback.pause();
123
- this.#audioContext = new AudioContext();
124
- await this.#audioContext.resume();
125
-
126
- if (this.#startToken !== startToken || !this.isConnected) {
127
- this.releaseMicrophone();
128
- return;
129
- }
130
-
131
- const audio = {};
132
- if (navigator.mediaDevices.getSupportedConstraints().autoGainControl) {
133
- // Preserve the microphone's dynamics for consistent offline normalization.
134
- audio.autoGainControl = false;
135
- }
136
-
137
- const mediaStream = await navigator.mediaDevices.getUserMedia({audio});
138
-
139
- if (this.#startToken !== startToken || !this.isConnected) {
140
- mediaStream.getTracks().forEach(track => track.stop());
141
- this.releaseMicrophone();
142
- return;
143
- }
144
-
145
- this.#mediaStream = mediaStream;
146
- this.#audioSource = this.#audioContext.createMediaStreamSource(mediaStream);
147
-
148
- this.#audioDelay = this.#audioContext.createDelay(RECORDING_EDGE_DELAY / 1000);
149
- this.#audioDelay.delayTime.value = RECORDING_EDGE_DELAY / 1000;
150
- this.#audioDestination = this.#audioContext.createMediaStreamDestination();
151
- this.#audioSource
152
- .connect(this.#audioDelay)
153
- .connect(this.#audioDestination);
154
-
155
- this.#chunks = [];
156
- this.#mediaRecorder = new MediaRecorder(this.#audioDestination.stream, {mimeType});
157
-
158
- this.#mediaRecorder.addEventListener('dataavailable', (event) => {
159
- if (event.data.size > 0) this.#chunks.push(event.data);
160
- });
161
-
162
- this.#mediaRecorder.onstop = () => this.finish();
163
-
164
- this.dataset.state = 'starting';
165
- this.setStatus('Starting…');
166
-
167
- // Audio reaches MediaRecorder 100 ms after it reaches the microphone. Starting
168
- // the encoder 200 ms after the click therefore retains audio beginning 100 ms
169
- // after pointer-up. If microphone setup took longer, wait only long enough for
170
- // the newly-created delay line to contain live audio.
171
- const startAt = Math.max(
172
- clickedAt + RECORDING_EDGE_DELAY * 2,
173
- performance.now() + RECORDING_EDGE_DELAY,
174
- );
175
- await new Promise(resolve => window.setTimeout(resolve, Math.max(0, startAt - performance.now())));
176
-
177
- if (this.#startToken !== startToken || !this.isConnected) return;
178
-
179
- this.#mediaRecorder.start();
180
- this.#startToken = null;
181
- this.startTimer();
182
-
183
- this.recordButton.disabled = false;
184
- this.recordButton.textContent = '■ Stop';
185
- this.dataset.state = 'recording';
186
- this.setStatus('Recording…');
187
- } catch (error) {
188
- if (this.#startToken !== startToken) return;
189
-
190
- this.#startToken = null;
191
- this.releaseMicrophone();
192
- this.recordButton.disabled = false;
193
- this.dataset.state = 'idle';
194
- this.setStatus(`Could not start recording: ${error.message}`, true);
195
- }
196
- }
197
-
198
- stop() {
199
- if (this.#mediaRecorder?.state === 'recording') {
200
- this.#recordingDuration = (performance.now() - this.#startedAt) / 1000;
201
-
202
- // The encoder receives audio through a 100 ms delay. Stopping immediately
203
- // excludes approximately the final 100 ms before this pointer-down event.
204
- this.#mediaRecorder.stop();
205
- this.recordButton.disabled = true;
206
- this.dataset.state = 'finishing';
207
- this.setStatus('Preparing recording…');
208
- }
209
- }
210
-
211
- finish() {
212
- this.stopTimer();
213
- this.releaseMicrophone();
214
- this.releaseRecordingURL();
215
-
216
- this.#recording = new Blob(this.#chunks, {type: 'audio/webm'});
217
- this.#recordingURL = URL.createObjectURL(this.#recording);
218
- this.playback.src = this.#recordingURL;
219
- this.playback.hidden = false;
220
- this.applyDurationButton.hidden = true;
221
-
222
- this.recordButton.disabled = false;
223
- this.recordButton.textContent = '● Retake';
224
- this.saveButton.disabled = this.#recording.size === 0;
225
- this.dataset.state = 'review';
226
- this.setStatus(this.#recording.size > 0 ? 'Review the recording, then save it.' : 'The recording was empty.', this.#recording.size === 0);
227
- }
228
-
229
- async save() {
230
- if (!this.#recording) return;
231
-
232
- this.saveButton.disabled = true;
233
- this.setStatus('Saving…');
234
-
235
- try {
236
- const url = new URL(this.url, window.location.href);
237
- let duration = null;
238
-
239
- if (this.updateDuration.checked) {
240
- duration = this.canonicalDuration();
241
- if (!duration) {
242
- throw new Error('Could not determine the recording duration.');
243
- }
244
-
245
- url.searchParams.set('duration', duration);
246
- }
247
-
248
- const response = await fetch(url, {
249
- method: 'PUT',
250
- headers: {'content-type': this.#recording.type},
251
- body: this.#recording,
252
- });
253
-
254
- if (!response.ok) {
255
- throw new Error((await response.text()) || `Could not save recording (${response.status}).`);
256
- }
257
-
258
- // Keep the reviewed blob loaded because replacing its source resets media metadata:
259
- this.#recording = null;
260
- if (duration) this.dataset.slideDuration = String(duration);
261
- this.updateDurationAction();
262
- const unit = duration === 1 ? 'second' : 'seconds';
263
- this.setStatus(duration ? `Recording saved. Slide duration set to ${duration} ${unit}.` : 'Recording saved.');
264
- } catch (error) {
265
- this.saveButton.disabled = false;
266
- this.setStatus(`Could not save recording: ${error.message}`, true);
267
- }
268
- }
269
-
270
- canonicalDuration() {
271
- const duration = this.recordingDuration();
272
- if (!Number.isFinite(duration) || duration <= 0) return null;
273
- return Math.max(1, Math.ceil(duration));
274
- }
275
-
276
- updateDurationAction() {
277
- const recordingDuration = this.canonicalDuration();
278
- const slideDuration = Number(this.dataset.slideDuration);
279
- this.applyDurationButton.hidden = Boolean(this.#recording) || !recordingDuration || recordingDuration === slideDuration;
280
- }
281
-
282
- async updateSlideDuration() {
283
- const duration = this.canonicalDuration();
284
- if (!duration) return;
285
-
286
- this.applyDurationButton.disabled = true;
287
- this.setStatus('Updating slide duration…');
288
-
289
- try {
290
- const url = new URL(this.url, window.location.href);
291
- url.searchParams.set('duration', duration);
292
- const response = await fetch(url, {method: 'PATCH'});
293
-
294
- if (!response.ok) {
295
- throw new Error((await response.text()) || `Could not update slide duration (${response.status}).`);
296
- }
297
-
298
- this.dataset.slideDuration = String(duration);
299
- this.applyDurationButton.disabled = false;
300
- this.applyDurationButton.hidden = true;
301
- const unit = duration === 1 ? 'second' : 'seconds';
302
- this.setStatus(`Slide duration set to ${duration} ${unit}.`);
303
- } catch (error) {
304
- this.applyDurationButton.disabled = false;
305
- this.setStatus(`Could not update slide duration: ${error.message}`, true);
306
- }
307
- }
308
-
309
- recordingDuration() {
310
- if (Number.isFinite(this.playback.duration) && this.playback.duration > 0) {
311
- return this.playback.duration;
312
- }
313
-
314
- return this.#recordingDuration;
315
- }
316
-
317
- startTimer() {
318
- this.#startedAt = performance.now();
319
- this.updateTimer();
320
- this.#timer = window.setInterval(() => this.updateTimer(), 250);
321
- }
322
-
323
- stopTimer() {
324
- if (this.#timer) window.clearInterval(this.#timer);
325
- this.#timer = null;
326
- }
327
-
328
- updateTimer() {
329
- const elapsed = Math.floor((performance.now() - this.#startedAt) / 1000);
330
- const minutes = Math.floor(elapsed / 60);
331
- const seconds = String(elapsed % 60).padStart(2, '0');
332
- this.time.textContent = `${minutes}:${seconds}`;
333
- }
334
-
335
- releaseMicrophone() {
336
- this.#mediaStream?.getTracks().forEach(track => track.stop());
337
- this.#mediaStream = null;
338
-
339
- this.#audioDestination?.stream.getTracks().forEach(track => track.stop());
340
- this.#audioSource?.disconnect();
341
- this.#audioDelay?.disconnect();
342
- this.#audioDestination?.disconnect();
343
- this.#audioContext?.close().catch(() => {});
344
-
345
- this.#audioContext = null;
346
- this.#audioSource = null;
347
- this.#audioDelay = null;
348
- this.#audioDestination = null;
349
- }
350
-
351
- releaseRecordingURL() {
352
- if (this.#recordingURL) URL.revokeObjectURL(this.#recordingURL);
353
- this.#recordingURL = null;
354
- }
355
-
356
- cacheBustedURL() {
357
- return `${this.url}&version=${Date.now()}`;
358
- }
359
-
360
- setStatus(message, error = false) {
361
- this.status.textContent = message;
362
- this.status.classList.toggle('error', error);
363
- }
364
- }
365
-
366
- customElements.define('presently-recorder', PresentlyRecorder);