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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/animating-slides.md +14 -0
- data/lib/presently/application.rb +4 -4
- data/lib/presently/home_view.rb +1 -1
- data/lib/presently/page.rb +1 -0
- data/lib/presently/presentation_controller.rb +10 -1
- data/lib/presently/{recording_view.rb → recorder_view.rb} +4 -27
- data/lib/presently/recording_controls_view.rb +44 -0
- data/lib/presently/slide_renderer.rb +3 -1
- data/lib/presently/version.rb +1 -1
- data/lib/presently.rb +1 -1
- data/public/_components/@socketry/presently/Presently/Scripts.js +3 -0
- data/public/_components/@socketry/presently/Presently/SlideRendering.js +8 -6
- data/public/_static/recorder.css +41 -32
- data/public/_static/slides.css +5 -0
- data/public/application.js +4 -4
- data/public/recording_controls.js +522 -0
- data/readme.md +6 -8
- data/releases.md +5 -0
- data.tar.gz.sig +0 -0
- metadata +4 -3
- metadata.gz.sig +0 -0
- data/public/recorder.js +0 -366
|
@@ -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);
|
data/readme.md
CHANGED
|
@@ -32,7 +32,7 @@ Please see the [project documentation](https://socketry.github.io/presently/) fo
|
|
|
32
32
|
|
|
33
33
|
### Recording Narration
|
|
34
34
|
|
|
35
|
-
Open `http://localhost:9292/
|
|
35
|
+
Open `http://localhost:9292/recorder` to record narration separately from the live presenter interface. Presently stores one WebM/Opus recording per slide under `audio/`, mirroring the slide's relative path:
|
|
36
36
|
|
|
37
37
|
``` text
|
|
38
38
|
slides/020-problem/010-overview.md
|
|
@@ -67,6 +67,11 @@ The task records the presentation at 1920×1080 and 30 frames per second by defa
|
|
|
67
67
|
|
|
68
68
|
Please see the [project releases](https://socketry.github.io/presently/releases/index) for all releases.
|
|
69
69
|
|
|
70
|
+
### v0.24.0
|
|
71
|
+
|
|
72
|
+
- Match slide duration to saved narration by default in the recorder.
|
|
73
|
+
- Keep scripted slides hidden until their initial script state has been applied.
|
|
74
|
+
|
|
70
75
|
### v0.23.0
|
|
71
76
|
|
|
72
77
|
- Keep narrated playback visuals synchronized when the document or inactive slide frames are hidden.
|
|
@@ -117,13 +122,6 @@ Please see the [project releases](https://socketry.github.io/presently/releases/
|
|
|
117
122
|
|
|
118
123
|
- Add support for organizing slides in nested directories. Every directory and slide filename must begin with a numeric prefix, and slides are ordered by relative path.
|
|
119
124
|
|
|
120
|
-
### v0.15.0
|
|
121
|
-
|
|
122
|
-
q
|
|
123
|
-
|
|
124
|
-
- Export works from current working directory.
|
|
125
|
-
- Remove explicit support for `morph` transition.
|
|
126
|
-
|
|
127
125
|
## See Also
|
|
128
126
|
|
|
129
127
|
- [lively](https://github.com/socketry/lively) — The real-time application framework that powers Presently.
|
data/releases.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Releases
|
|
2
2
|
|
|
3
|
+
## v0.24.0
|
|
4
|
+
|
|
5
|
+
- Match slide duration to saved narration by default in the recorder.
|
|
6
|
+
- Keep scripted slides hidden until their initial script state has been applied.
|
|
7
|
+
|
|
3
8
|
## v0.23.0
|
|
4
9
|
|
|
5
10
|
- Keep narrated playback visuals synchronized when the document or inactive slide frames are hidden.
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: presently
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.24.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Samuel Williams
|
|
@@ -137,7 +137,8 @@ files:
|
|
|
137
137
|
- lib/presently/presentation.rb
|
|
138
138
|
- lib/presently/presentation_controller.rb
|
|
139
139
|
- lib/presently/presenter_view.rb
|
|
140
|
-
- lib/presently/
|
|
140
|
+
- lib/presently/recorder_view.rb
|
|
141
|
+
- lib/presently/recording_controls_view.rb
|
|
141
142
|
- lib/presently/recordings.rb
|
|
142
143
|
- lib/presently/recordings/normalizer.rb
|
|
143
144
|
- lib/presently/slide.rb
|
|
@@ -261,7 +262,7 @@ files:
|
|
|
261
262
|
- public/application.js
|
|
262
263
|
- public/export.js
|
|
263
264
|
- public/playback.js
|
|
264
|
-
- public/
|
|
265
|
+
- public/recording_controls.js
|
|
265
266
|
- readme.md
|
|
266
267
|
- releases.md
|
|
267
268
|
- templates/code.xrb
|
metadata.gz.sig
CHANGED
|
Binary file
|