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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/animating-slides.md +14 -0
- data/lib/presently/application.rb +39 -8
- data/lib/presently/home_view.rb +1 -1
- data/lib/presently/page.rb +1 -0
- data/lib/presently/playback.xrb +9 -3
- data/lib/presently/presentation_controller.rb +10 -1
- data/lib/presently/{recording_view.rb → recorder_view.rb} +4 -19
- data/lib/presently/recording_controls_view.rb +44 -0
- data/lib/presently/slide.rb +42 -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 +20 -15
- data/public/_static/recorder.css +48 -25
- data/public/_static/slides.css +5 -0
- data/public/application.js +9 -8
- data/public/playback.js +34 -35
- data/public/recording_controls.js +522 -0
- data/readme.md +14 -14
- data/releases.md +13 -0
- data.tar.gz.sig +0 -0
- metadata +4 -3
- metadata.gz.sig +2 -2
- data/public/recorder.js +0 -282
data/public/application.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {SlideRendering} from '@socketry/presently';
|
|
1
|
+
import './recording_controls.js';
|
|
3
2
|
|
|
4
|
-
import '
|
|
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,
|
|
13
|
-
const rendering = new SlideRendering(view,
|
|
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
|
|
26
|
-
|
|
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-
|
|
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 {
|
|
2
|
-
import
|
|
1
|
+
import {SlideRendering} from '@socketry/presently';
|
|
2
|
+
import morphdom from 'morphdom';
|
|
3
3
|
|
|
4
|
-
const
|
|
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
|
|
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 ===
|
|
31
|
-
counter.textContent = `${currentIndex + 1} / ${
|
|
31
|
+
nextButton.disabled = currentIndex === slideTemplates.length - 1;
|
|
32
|
+
counter.textContent = `${currentIndex + 1} / ${slideTemplates.length}`;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function stopCurrent() {
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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 >=
|
|
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
|
-
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
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 (!
|
|
240
|
-
if (audioTracks.size !==
|
|
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);
|