presently 0.22.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.
@@ -1,7 +1,7 @@
1
- import { Live } from 'live';
2
- import {SlideRendering} from '@socketry/presently';
1
+ import './recording_controls.js';
3
2
 
4
- import './recorder.js';
3
+ import {Live} from '@socketry/live';
4
+ import {SlideRendering} from '@socketry/presently';
5
5
 
6
6
  const SLIDE_RENDER_EVENT = 'presently:slide:render';
7
7
 
@@ -9,8 +9,8 @@ let live = null;
9
9
 
10
10
  let activeRendering = null;
11
11
 
12
- function activateRendering(view, detail = {}) {
13
- const rendering = new SlideRendering(view, detail);
12
+ function activateRendering(view, {transition = null} = {}) {
13
+ const rendering = new SlideRendering(view, {transition});
14
14
  const previousRendering = activeRendering;
15
15
  activeRendering = rendering;
16
16
  previousRendering?.dispose();
@@ -22,8 +22,9 @@ async function renderSlide(event) {
22
22
  const view = event.target.closest?.('live-view');
23
23
  if (!view) return;
24
24
 
25
- const rendering = activateRendering(view, event.detail);
26
- await rendering.render(live);
25
+ const {html, transition} = event.detail ?? {};
26
+ const rendering = activateRendering(view, {transition});
27
+ await rendering.render(renderView => live.update(renderView.id, html));
27
28
  }
28
29
 
29
30
  document.addEventListener(SLIDE_RENDER_EVENT, (event) => {
@@ -51,7 +52,7 @@ document.addEventListener('change', (event) => {
51
52
 
52
53
  // Keyboard navigation
53
54
  document.addEventListener('keydown', (event) => {
54
- if (event.target.closest('button, input, textarea, select, audio, presently-recorder')) return;
55
+ if (event.target.closest('button, input, textarea, select, audio, presently-recording-controls')) return;
55
56
 
56
57
  const liveView = document.querySelector('live-view');
57
58
  if (!liveView) return;
data/public/playback.js CHANGED
@@ -1,7 +1,8 @@
1
- import {applyCodeFocus, runScript} from '@socketry/presently';
2
- import Syntax from '@socketry/syntax';
1
+ import {SlideRendering} from '@socketry/presently';
2
+ import morphdom from 'morphdom';
3
3
 
4
- const frames = Array.from(document.querySelectorAll('.playback-frame'));
4
+ const frame = document.querySelector('.playback-frame');
5
+ const slideTemplates = Array.from(document.querySelectorAll('.playback-slides template'));
5
6
  const audioTracks = new Map(
6
7
  Array.from(document.querySelectorAll('.playback-audio audio')).map(audio => [Number(audio.dataset.index), audio]),
7
8
  );
@@ -14,7 +15,7 @@ const nextButton = document.querySelector('.playback-next');
14
15
  const counter = document.querySelector('.playback-counter');
15
16
 
16
17
  let currentIndex = 0;
17
- let currentScript = null;
18
+ let currentRendering = null;
18
19
  let currentAudio = null;
19
20
  let playing = false;
20
21
  let transitioning = false;
@@ -27,13 +28,13 @@ function setStatus(message, error = false) {
27
28
  function updateControls() {
28
29
  toggleButton.textContent = playing ? '❚❚' : '▶';
29
30
  previousButton.disabled = currentIndex === 0;
30
- nextButton.disabled = currentIndex === frames.length - 1;
31
- counter.textContent = `${currentIndex + 1} / ${frames.length}`;
31
+ nextButton.disabled = currentIndex === slideTemplates.length - 1;
32
+ counter.textContent = `${currentIndex + 1} / ${slideTemplates.length}`;
32
33
  }
33
34
 
34
35
  function stopCurrent() {
35
- currentScript?.dispose();
36
- currentScript = null;
36
+ currentRendering?.dispose();
37
+ currentRendering = null;
37
38
 
38
39
  if (currentAudio) {
39
40
  currentAudio.pause();
@@ -42,40 +43,40 @@ function stopCurrent() {
42
43
  }
43
44
  }
44
45
 
45
- function activateFrame(index) {
46
- frames.forEach((frame, frameIndex) => {
47
- frame.hidden = frameIndex !== index;
46
+ async function activateFrame(index, {transition = true} = {}) {
47
+ const slideTemplate = slideTemplates[index];
48
+ const nextFrame = document.createElement('div');
49
+ nextFrame.id = frame.id;
50
+ nextFrame.className = frame.className;
51
+ nextFrame.dataset.index = slideTemplate.dataset.index;
52
+ nextFrame.dataset.transition = slideTemplate.dataset.transition;
53
+ nextFrame.dataset.duration = slideTemplate.dataset.duration;
54
+ nextFrame.append(slideTemplate.content.cloneNode(true));
55
+
56
+ const rendering = new SlideRendering(frame, {
57
+ transition: transition ? slideTemplate.dataset.transition : null,
48
58
  });
59
+ currentRendering = rendering;
60
+
61
+ const rendered = await rendering.render(renderView => morphdom(renderView, nextFrame));
62
+ if (!rendered || currentRendering !== rendering) return false;
49
63
 
50
64
  currentIndex = index;
51
- const slide = frames[index].querySelector('.slide');
52
- currentScript = runScript(slide);
53
65
  updateControls();
66
+ return true;
54
67
  }
55
68
 
56
69
  async function show(index, {transition = true} = {}) {
57
- if (transitioning || index < 0 || index >= frames.length) return false;
70
+ if (transitioning || index < 0 || index >= slideTemplates.length) return false;
58
71
 
59
72
  transitioning = true;
60
73
  stopCurrent();
61
74
 
62
- const transitionName = frames[index].dataset.transition;
63
- const swap = () => activateFrame(index);
64
-
65
75
  try {
66
- if (transition && transitionName && document.startViewTransition) {
67
- document.documentElement.dataset.transition = transitionName;
68
- const viewTransition = document.startViewTransition(swap);
69
- await viewTransition.updateCallbackDone;
70
- } else {
71
- swap();
72
- }
76
+ return await activateFrame(index, {transition});
73
77
  } finally {
74
- delete document.documentElement.dataset.transition;
75
78
  transitioning = false;
76
79
  }
77
-
78
- return true;
79
80
  }
80
81
 
81
82
  function finish() {
@@ -115,7 +116,7 @@ async function playCurrent() {
115
116
 
116
117
  async function handleEnded() {
117
118
  try {
118
- if (currentIndex === frames.length - 1) {
119
+ if (currentIndex === slideTemplates.length - 1) {
119
120
  finish();
120
121
  return;
121
122
  }
@@ -146,7 +147,7 @@ async function start() {
146
147
  startButton.disabled = true;
147
148
 
148
149
  try {
149
- if (currentIndex === frames.length - 1 || startButton.textContent.includes('again')) {
150
+ if (currentIndex === slideTemplates.length - 1 || startButton.textContent.includes('again')) {
150
151
  await show(0, {transition: false});
151
152
  }
152
153
 
@@ -161,7 +162,7 @@ window.__PRESENTLY_PLAYBACK_START = start;
161
162
 
162
163
  async function move(offset) {
163
164
  const wasPlaying = playing;
164
- const index = Math.max(0, Math.min(frames.length - 1, currentIndex + offset));
165
+ const index = Math.max(0, Math.min(slideTemplates.length - 1, currentIndex + offset));
165
166
  if (index === currentIndex) return;
166
167
 
167
168
  playing = false;
@@ -236,17 +237,15 @@ async function waitForAudioMetadata(audio) {
236
237
  }
237
238
 
238
239
  async function prepare() {
239
- if (!frames.length) throw new Error('The presentation has no slides.');
240
- if (audioTracks.size !== frames.length) throw new Error('Every slide requires a narration recording.');
240
+ if (!slideTemplates.length) throw new Error('The presentation has no slides.');
241
+ if (audioTracks.size !== slideTemplates.length) throw new Error('Every slide requires a narration recording.');
241
242
 
242
243
  await Promise.all([
243
- Syntax.highlight(),
244
244
  document.fonts.ready,
245
245
  ...Array.from(audioTracks.values()).map(waitForAudioMetadata),
246
246
  ]);
247
- await applyCodeFocus();
248
247
 
249
- activateFrame(0);
248
+ await activateFrame(0, {transition: false});
250
249
  startButton.disabled = false;
251
250
  setStatus('Ready.');
252
251
 
@@ -0,0 +1,522 @@
1
+ import {ViewElement} from '@socketry/live';
2
+
3
+ const RECORDING_EDGE_DELAY = 100;
4
+ const RECORDER_INTERFACE = `
5
+ <div class="recording-actions" part="actions">
6
+ <button class="recording-toggle" part="toggle" type="button">● Record</button>
7
+ <button class="recording-save" part="save" type="button" disabled>Save</button>
8
+ <span class="recording-indicator" part="indicator" aria-hidden="true"></span>
9
+ <span class="recording-time" part="time">0:00</span>
10
+ </div>
11
+ <audio class="recording-playback" part="playback" controls preload="metadata"></audio>
12
+ <button class="recording-apply-duration" part="apply-duration" type="button" disabled>Update Slide Duration</button>
13
+ <label class="recording-duration-option" part="duration-option">
14
+ <input class="recording-update-duration" type="checkbox" checked>
15
+ <span>Update slide duration to match recording</span>
16
+ </label>
17
+ `;
18
+
19
+ // Records and reviews narration. This Live view receives slide inputs on its
20
+ // host while its shadow root owns the complete client-rendered interface.
21
+ export class PresentlyRecordingControls extends ViewElement {
22
+ static observedAttributes = ['data-slide-index', 'data-slide-duration', 'data-recording-state'];
23
+
24
+ #mediaRecorder = null;
25
+ #mediaStream = null;
26
+ #audioContext = null;
27
+ #audioSource = null;
28
+ #audioDelay = null;
29
+ #audioDestination = null;
30
+ #chunks = [];
31
+ #recording = null;
32
+ #recordingURL = null;
33
+ #recordingDuration = null;
34
+ #startedAt = null;
35
+ #timer = null;
36
+ #startToken = null;
37
+ #cancelPlaybackLoad = null;
38
+ #ignoreClick = false;
39
+ #hostReconciliationQueued = false;
40
+ #reflectingState = false;
41
+ #initialized = false;
42
+ #sessionToken = {};
43
+ #recordingAvailability = 'loading';
44
+
45
+ connectedCallback() {
46
+ super.connectedCallback();
47
+
48
+ if (this.#initialized) {
49
+ this.switchSlide();
50
+ return;
51
+ }
52
+
53
+ this.#initialized = true;
54
+ const root = this.attachShadow({mode: 'open'});
55
+ root.innerHTML = RECORDER_INTERFACE;
56
+ this.recordButton = root.querySelector('.recording-toggle');
57
+ this.saveButton = root.querySelector('.recording-save');
58
+ this.playback = root.querySelector('.recording-playback');
59
+ this.applyDurationButton = root.querySelector('.recording-apply-duration');
60
+ this.updateDuration = root.querySelector('.recording-update-duration');
61
+ this.time = root.querySelector('.recording-time');
62
+ this.#recordingAvailability = this.recordingAvailability();
63
+
64
+ // Stop as soon as the pointer is pressed so the delayed audio excludes the
65
+ // click. The click handler provides both starting and keyboard activation.
66
+ this.recordButton.addEventListener('pointerdown', () => {
67
+ if (this.isRecording()) {
68
+ this.#ignoreClick = true;
69
+ this.stop();
70
+ }
71
+ });
72
+ this.recordButton.addEventListener('click', () => {
73
+ if (this.#ignoreClick) {
74
+ this.#ignoreClick = false;
75
+ return;
76
+ }
77
+
78
+ if (this.recordButton.disabled) return;
79
+
80
+ if (this.isRecording()) {
81
+ this.stop();
82
+ } else {
83
+ this.start(performance.now());
84
+ }
85
+ });
86
+ this.saveButton.addEventListener('click', () => this.save());
87
+ this.applyDurationButton.addEventListener('click', () => this.updateSlideDuration());
88
+ this.playback.addEventListener('loadedmetadata', () => this.updateDurationAction());
89
+
90
+ this.loadPlayback();
91
+ this.updateInterface();
92
+ }
93
+
94
+ attributeChangedCallback(name, oldValue, newValue) {
95
+ if (oldValue === null || oldValue === newValue || !this.isConnected) return;
96
+
97
+ if (name === 'data-recording-state' && this.#reflectingState) return;
98
+
99
+ if (name === 'data-slide-duration') {
100
+ this.updateInterface();
101
+ return;
102
+ }
103
+
104
+ if (name === 'data-recording-state' && newValue !== 'missing' && newValue !== 'present') {
105
+ this.updateInterface();
106
+ return;
107
+ }
108
+
109
+ if (this.#hostReconciliationQueued) return;
110
+
111
+ // Morphdom updates attributes individually. Wait until its synchronous
112
+ // reconciliation is complete before reading the new slide inputs.
113
+ this.#hostReconciliationQueued = true;
114
+ queueMicrotask(() => {
115
+ this.#hostReconciliationQueued = false;
116
+ if (this.isConnected) this.switchSlide();
117
+ });
118
+ }
119
+
120
+ disconnectedCallback() {
121
+ this.resetSession();
122
+ this.setRecordingState(this.#recordingAvailability);
123
+ super.disconnectedCallback();
124
+ }
125
+
126
+ get url() {
127
+ return this.dataset.recordingUrl;
128
+ }
129
+
130
+ isRecording() {
131
+ return this.#mediaRecorder?.state === 'recording';
132
+ }
133
+
134
+ switchSlide() {
135
+ this.resetSession();
136
+ this.#recordingAvailability = this.recordingAvailability();
137
+ this.time.textContent = '0:00';
138
+ const loading = this.loadPlayback();
139
+ this.updateInterface();
140
+ return loading;
141
+ }
142
+
143
+ resetSession() {
144
+ this.#sessionToken = {};
145
+ this.#startToken = null;
146
+ this.#cancelPlaybackLoad?.();
147
+
148
+ if (this.#mediaRecorder) {
149
+ this.#mediaRecorder.onstop = null;
150
+ if (this.#mediaRecorder.state === 'recording') this.#mediaRecorder.stop();
151
+ }
152
+
153
+ this.releaseMicrophone();
154
+ this.stopTimer();
155
+ this.releaseRecordingURL();
156
+ this.#mediaRecorder = null;
157
+ this.#chunks = [];
158
+ this.#recording = null;
159
+ this.#recordingDuration = null;
160
+ this.#ignoreClick = false;
161
+ }
162
+
163
+ async loadPlayback() {
164
+ const sessionToken = this.#sessionToken;
165
+ this.playback.pause();
166
+
167
+ if (this.dataset.recordingState !== 'present') {
168
+ this.clearPlaybackSource();
169
+ return;
170
+ }
171
+
172
+ try {
173
+ const loaded = await this.waitForPlayback(() => {
174
+ if (this.playback.getAttribute('src') !== this.url) {
175
+ this.playback.src = this.url;
176
+ }
177
+ });
178
+ if (loaded === null || sessionToken !== this.#sessionToken || !this.isConnected) return;
179
+
180
+ if (!loaded) console.error('Could not load the existing recording.');
181
+ } catch (error) {
182
+ if (sessionToken !== this.#sessionToken || !this.isConnected) return;
183
+ console.error('Could not load the existing recording.', error);
184
+ }
185
+ }
186
+
187
+ async start(clickedAt) {
188
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
189
+ if (!window.MediaRecorder || !navigator.mediaDevices?.getUserMedia || !AudioContext) {
190
+ console.error('Audio recording is not supported by this browser.');
191
+ return;
192
+ }
193
+
194
+ const mimeType = 'audio/webm;codecs=opus';
195
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
196
+ console.error(`${mimeType} recording is not supported by this browser.`);
197
+ return;
198
+ }
199
+
200
+ const startToken = this.#startToken = {};
201
+ const fallbackState = this.#recording ? 'review' : this.#recordingAvailability;
202
+ this.#recordingDuration = null;
203
+ this.setRecordingState('preparing');
204
+
205
+ try {
206
+ this.playback.pause();
207
+ this.#audioContext = new AudioContext();
208
+ await this.#audioContext.resume();
209
+
210
+ if (this.#startToken !== startToken || !this.isConnected) {
211
+ this.releaseMicrophone();
212
+ return;
213
+ }
214
+
215
+ const audio = {};
216
+ if (navigator.mediaDevices.getSupportedConstraints().autoGainControl) {
217
+ // Preserve the microphone's dynamics for consistent offline normalization.
218
+ audio.autoGainControl = false;
219
+ }
220
+
221
+ const mediaStream = await navigator.mediaDevices.getUserMedia({audio});
222
+
223
+ if (this.#startToken !== startToken || !this.isConnected) {
224
+ mediaStream.getTracks().forEach(track => track.stop());
225
+ this.releaseMicrophone();
226
+ return;
227
+ }
228
+
229
+ this.#mediaStream = mediaStream;
230
+ this.#audioSource = this.#audioContext.createMediaStreamSource(mediaStream);
231
+
232
+ this.#audioDelay = this.#audioContext.createDelay(RECORDING_EDGE_DELAY / 1000);
233
+ this.#audioDelay.delayTime.value = RECORDING_EDGE_DELAY / 1000;
234
+ this.#audioDestination = this.#audioContext.createMediaStreamDestination();
235
+ this.#audioSource
236
+ .connect(this.#audioDelay)
237
+ .connect(this.#audioDestination);
238
+
239
+ this.#chunks = [];
240
+ const mediaRecorder = this.#mediaRecorder = new MediaRecorder(this.#audioDestination.stream, {mimeType});
241
+
242
+ mediaRecorder.addEventListener('dataavailable', (event) => {
243
+ if (this.#mediaRecorder === mediaRecorder && event.data.size > 0) this.#chunks.push(event.data);
244
+ });
245
+
246
+ mediaRecorder.onstop = () => {
247
+ if (this.#mediaRecorder === mediaRecorder) this.finish();
248
+ };
249
+
250
+ this.setRecordingState('starting');
251
+
252
+ // Audio reaches MediaRecorder 100 ms after it reaches the microphone. Starting
253
+ // the encoder 200 ms after the click therefore retains audio beginning 100 ms
254
+ // after pointer-up. If microphone setup took longer, wait only long enough for
255
+ // the newly-created delay line to contain live audio.
256
+ const startAt = Math.max(
257
+ clickedAt + RECORDING_EDGE_DELAY * 2,
258
+ performance.now() + RECORDING_EDGE_DELAY,
259
+ );
260
+ await new Promise(resolve => window.setTimeout(resolve, Math.max(0, startAt - performance.now())));
261
+
262
+ if (this.#startToken !== startToken || !this.isConnected) return;
263
+
264
+ this.#mediaRecorder.start();
265
+ this.#startToken = null;
266
+ this.startTimer();
267
+
268
+ this.setRecordingState('recording');
269
+ } catch (error) {
270
+ if (this.#startToken !== startToken) return;
271
+
272
+ this.#startToken = null;
273
+ this.releaseMicrophone();
274
+ this.setRecordingState(fallbackState);
275
+ console.error('Could not start recording.', error);
276
+ }
277
+ }
278
+
279
+ stop() {
280
+ if (this.#mediaRecorder?.state === 'recording') {
281
+ this.#recordingDuration = (performance.now() - this.#startedAt) / 1000;
282
+
283
+ // The encoder receives audio through a 100 ms delay. Stopping immediately
284
+ // excludes approximately the final 100 ms before this pointer-down event.
285
+ this.#mediaRecorder.stop();
286
+ this.setRecordingState('finishing');
287
+ }
288
+ }
289
+
290
+ async finish() {
291
+ this.stopTimer();
292
+ this.releaseMicrophone();
293
+ this.releaseRecordingURL();
294
+
295
+ this.#recording = new Blob(this.#chunks, {type: 'audio/webm'});
296
+ this.#recordingURL = URL.createObjectURL(this.#recording);
297
+
298
+ if (this.#recording.size === 0) {
299
+ this.#recording = null;
300
+ this.releaseRecordingURL();
301
+ this.setRecordingState(this.#recordingAvailability);
302
+ return;
303
+ }
304
+
305
+ const loaded = await this.setPlaybackSource(this.#recordingURL);
306
+ if (loaded === null || !this.isConnected) return;
307
+
308
+ if (loaded && this.playbackReady()) {
309
+ this.setRecordingState('review');
310
+ } else {
311
+ this.#recording = null;
312
+ this.releaseRecordingURL();
313
+ await this.loadPlayback();
314
+ this.setRecordingState(this.#recordingAvailability);
315
+ console.error('The recording could not be played.');
316
+ }
317
+ }
318
+
319
+ async save() {
320
+ if (!this.#recording) return;
321
+ const sessionToken = this.#sessionToken;
322
+
323
+ this.setRecordingState('saving');
324
+
325
+ try {
326
+ const url = new URL(this.url, window.location.href);
327
+ let duration = null;
328
+
329
+ if (this.updateDuration.checked) {
330
+ duration = this.canonicalDuration();
331
+ if (!duration) {
332
+ throw new Error('Could not determine the recording duration.');
333
+ }
334
+
335
+ url.searchParams.set('duration', duration);
336
+ }
337
+
338
+ const response = await fetch(url, {
339
+ method: 'PUT',
340
+ headers: {'content-type': this.#recording.type},
341
+ body: this.#recording,
342
+ });
343
+
344
+ if (!response.ok) {
345
+ throw new Error((await response.text()) || `Could not save recording (${response.status}).`);
346
+ }
347
+
348
+ if (sessionToken !== this.#sessionToken || !this.isConnected) return;
349
+
350
+ // Keep the reviewed blob loaded because replacing its source resets media metadata:
351
+ this.#recording = null;
352
+ if (duration) this.dataset.slideDuration = String(duration);
353
+ this.setRecordingState('present');
354
+ } catch (error) {
355
+ if (sessionToken !== this.#sessionToken || !this.isConnected) return;
356
+ this.setRecordingState('review');
357
+ console.error('Could not save recording.', error);
358
+ }
359
+ }
360
+
361
+ canonicalDuration() {
362
+ const duration = this.recordingDuration();
363
+ if (!Number.isFinite(duration) || duration <= 0) return null;
364
+ return Math.max(1, Math.ceil(duration));
365
+ }
366
+
367
+ updateDurationAction() {
368
+ this.updateInterface();
369
+ }
370
+
371
+ async updateSlideDuration() {
372
+ const duration = this.canonicalDuration();
373
+ if (!duration) return;
374
+ const sessionToken = this.#sessionToken;
375
+
376
+ this.setRecordingState('updating');
377
+
378
+ try {
379
+ const url = new URL(this.url, window.location.href);
380
+ url.searchParams.set('duration', duration);
381
+ const response = await fetch(url, {method: 'PATCH'});
382
+
383
+ if (!response.ok) {
384
+ throw new Error((await response.text()) || `Could not update slide duration (${response.status}).`);
385
+ }
386
+
387
+ if (sessionToken !== this.#sessionToken || !this.isConnected) return;
388
+
389
+ this.dataset.slideDuration = String(duration);
390
+ this.setRecordingState(this.#recordingAvailability);
391
+ } catch (error) {
392
+ if (sessionToken !== this.#sessionToken || !this.isConnected) return;
393
+ this.setRecordingState(this.#recordingAvailability);
394
+ console.error('Could not update slide duration.', error);
395
+ }
396
+ }
397
+
398
+ recordingDuration() {
399
+ if (this.playbackReady()) {
400
+ return this.playback.duration;
401
+ }
402
+
403
+ return this.#recordingDuration;
404
+ }
405
+
406
+ playbackReady() {
407
+ return Number.isFinite(this.playback.duration) && this.playback.duration > 0;
408
+ }
409
+
410
+ waitForPlayback(update = null) {
411
+ this.#cancelPlaybackLoad?.();
412
+
413
+ return new Promise(resolve => {
414
+ const finish = (result) => {
415
+ this.playback.removeEventListener('loadedmetadata', onLoaded);
416
+ this.playback.removeEventListener('error', onError);
417
+ if (this.#cancelPlaybackLoad === cancel) this.#cancelPlaybackLoad = null;
418
+ resolve(result);
419
+ };
420
+ const onLoaded = () => finish(this.playbackReady());
421
+ const onError = () => finish(false);
422
+ const cancel = () => finish(null);
423
+
424
+ this.playback.addEventListener('loadedmetadata', onLoaded);
425
+ this.playback.addEventListener('error', onError);
426
+ this.#cancelPlaybackLoad = cancel;
427
+
428
+ update?.();
429
+ if (this.playbackReady()) finish(true);
430
+ else if (this.playback.error) finish(false);
431
+ });
432
+ }
433
+
434
+ setPlaybackSource(source) {
435
+ return this.waitForPlayback(() => {
436
+ this.playback.src = source;
437
+ });
438
+ }
439
+
440
+ clearPlaybackSource() {
441
+ if (this.playback.hasAttribute('src')) this.playback.removeAttribute('src');
442
+ if (this.playback.currentSrc) this.playback.load();
443
+ }
444
+
445
+ setRecordingState(state) {
446
+ if (state === 'missing' || state === 'present') this.#recordingAvailability = state;
447
+
448
+ this.#reflectingState = true;
449
+ try {
450
+ this.dataset.recordingState = state;
451
+ } finally {
452
+ this.#reflectingState = false;
453
+ }
454
+ this.updateInterface();
455
+ }
456
+
457
+ recordingAvailability() {
458
+ const state = this.dataset.recordingState;
459
+ return state === 'missing' || state === 'present' ? state : 'loading';
460
+ }
461
+
462
+ updateInterface() {
463
+ if (!this.recordButton) return;
464
+
465
+ const state = this.dataset.recordingState || 'loading';
466
+ const playbackReady = this.playbackReady();
467
+ const recording = state === 'recording';
468
+ const reviewing = state === 'review';
469
+ const busy = ['preparing', 'starting', 'finishing', 'saving', 'updating'].includes(state);
470
+ const canRecord = recording || (!busy && (reviewing || ['missing', 'present'].includes(state)));
471
+
472
+ this.recordButton.textContent = recording ? '■ Stop' : reviewing || state === 'present' ? '● Retake' : '● Record';
473
+ this.recordButton.disabled = !canRecord;
474
+ this.saveButton.disabled = state !== 'review' || !this.#recording?.size || !playbackReady;
475
+
476
+ const recordingDuration = this.canonicalDuration();
477
+ const slideDuration = Number(this.dataset.slideDuration);
478
+ this.applyDurationButton.disabled = state !== 'present' || !playbackReady || !recordingDuration || recordingDuration === slideDuration;
479
+ }
480
+
481
+ startTimer() {
482
+ this.#startedAt = performance.now();
483
+ this.updateTimer();
484
+ this.#timer = window.setInterval(() => this.updateTimer(), 250);
485
+ }
486
+
487
+ stopTimer() {
488
+ if (this.#timer) window.clearInterval(this.#timer);
489
+ this.#timer = null;
490
+ }
491
+
492
+ updateTimer() {
493
+ const elapsed = Math.floor((performance.now() - this.#startedAt) / 1000);
494
+ const minutes = Math.floor(elapsed / 60);
495
+ const seconds = String(elapsed % 60).padStart(2, '0');
496
+ this.time.textContent = `${minutes}:${seconds}`;
497
+ }
498
+
499
+ releaseMicrophone() {
500
+ this.#mediaStream?.getTracks().forEach(track => track.stop());
501
+ this.#mediaStream = null;
502
+
503
+ this.#audioDestination?.stream.getTracks().forEach(track => track.stop());
504
+ this.#audioSource?.disconnect();
505
+ this.#audioDelay?.disconnect();
506
+ this.#audioDestination?.disconnect();
507
+ this.#audioContext?.close().catch(() => {});
508
+
509
+ this.#audioContext = null;
510
+ this.#audioSource = null;
511
+ this.#audioDelay = null;
512
+ this.#audioDestination = null;
513
+ }
514
+
515
+ releaseRecordingURL() {
516
+ if (this.#recordingURL) URL.revokeObjectURL(this.#recordingURL);
517
+ this.#recordingURL = null;
518
+ }
519
+
520
+ }
521
+
522
+ customElements.define('presently-recording-controls', PresentlyRecordingControls);