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.
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/record` 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:
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,19 @@ 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
+
75
+ ### v0.23.0
76
+
77
+ - Keep narrated playback visuals synchronized when the document or inactive slide frames are hidden.
78
+ - Keep recorded audio duration visible after saving a reviewed take.
79
+ - Prevent stopping a pointer-initiated recording from immediately starting a retake.
80
+ - Update slide timing from new or existing narration recordings.
81
+ - Render audience and narrated playback slides through the same client-side lifecycle so incremental builds behave consistently.
82
+
70
83
  ### v0.22.0
71
84
 
72
85
  - Split built-in slide and interface styles into focused stylesheets while retaining `index.css` for shared defaults.
@@ -109,19 +122,6 @@ Please see the [project releases](https://socketry.github.io/presently/releases/
109
122
 
110
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.
111
124
 
112
- ### v0.15.0
113
-
114
- q
115
-
116
- - Export works from current working directory.
117
- - Remove explicit support for `morph` transition.
118
-
119
- ### v0.14.0
120
-
121
- - Increase code font size by 50%.
122
- - Add support for includes using `![[path]]` syntax.
123
- - Add `bake presently:rehearse` tasks for updating timing information.
124
-
125
125
  ## See Also
126
126
 
127
127
  - [lively](https://github.com/socketry/lively) — The real-time application framework that powers Presently.
data/releases.md CHANGED
@@ -1,5 +1,18 @@
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
+
8
+ ## v0.23.0
9
+
10
+ - Keep narrated playback visuals synchronized when the document or inactive slide frames are hidden.
11
+ - Keep recorded audio duration visible after saving a reviewed take.
12
+ - Prevent stopping a pointer-initiated recording from immediately starting a retake.
13
+ - Update slide timing from new or existing narration recordings.
14
+ - Render audience and narrated playback slides through the same client-side lifecycle so incremental builds behave consistently.
15
+
3
16
  ## v0.22.0
4
17
 
5
18
  - Split built-in slide and interface styles into focused stylesheets while retaining `index.css` for shared defaults.
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.22.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/recording_view.rb
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/recorder.js
265
+ - public/recording_controls.js
265
266
  - readme.md
266
267
  - releases.md
267
268
  - templates/code.xrb
metadata.gz.sig CHANGED
@@ -1,2 +1,2 @@
1
- ��פ���[+6���-�I�6ô�Q7L�]��`�as_��� �ux̓Qdp.��LD� $��*�c s���R�otvC�W�][�zx!�.ȱ��/�8����B��lr�y�4�cf1e:G����@��9�J��#� UȔ�#��Q҃b}yB���U�1&�壁����e,7<���P��{s��.�xnyipr
2
- ��
1
+ j�2g�s�[.��2~w�}uI���K; C� z��:;]��v�@�:��k4M�$+�Y�+ p5���7A�n;<���,��%G��QF�e�X�P�aZS�'��HxVl��+���dn���.��jEҘ_�"��X05��A�!T���w�f�L�F�~c�����d.�~�k�F���)I�N��_����Dh�jLr��~�&�= {�Â�߲������I��� ��0�oq��]v[�_?Y �{2���h� �m��5���ˑBK�� b,)�M �z#B�����dI�^~#J(ʋ~�E^Y�.�
2
+ �4\����Z`����l���� X��`�8�Y��H]Wd�g�����#W��0�K�B�*����)Y��
data/public/recorder.js DELETED
@@ -1,282 +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
- 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
- #startedAt = null;
17
- #timer = null;
18
- #startToken = null;
19
-
20
- connectedCallback() {
21
- this.recordButton = this.querySelector('.recording-toggle');
22
- this.saveButton = this.querySelector('.recording-save');
23
- this.playback = this.querySelector('.recording-playback');
24
- this.status = this.querySelector('.recording-status');
25
- this.time = this.querySelector('.recording-time');
26
-
27
- // Stop as soon as the pointer is pressed so the delayed audio excludes the
28
- // click. The click handler provides both starting and keyboard activation.
29
- this.recordButton.addEventListener('pointerdown', () => {
30
- if (this.#mediaRecorder?.state === 'recording') this.stop();
31
- });
32
- this.recordButton.addEventListener('click', () => {
33
- if (this.recordButton.disabled) return;
34
-
35
- if (this.#mediaRecorder?.state === 'recording') {
36
- this.stop();
37
- } else {
38
- this.start(performance.now());
39
- }
40
- });
41
- this.saveButton.addEventListener('click', () => this.save());
42
-
43
- this.loadExisting();
44
- }
45
-
46
- disconnectedCallback() {
47
- this.#startToken = null;
48
-
49
- if (this.#mediaRecorder?.state === 'recording') {
50
- this.#mediaRecorder.onstop = null;
51
- this.#mediaRecorder.stop();
52
- }
53
-
54
- this.releaseMicrophone();
55
- this.stopTimer();
56
- this.releaseRecordingURL();
57
- }
58
-
59
- get url() {
60
- return this.dataset.recordingUrl;
61
- }
62
-
63
- async loadExisting() {
64
- try {
65
- const response = await fetch(this.url, {method: 'HEAD', cache: 'no-store'});
66
-
67
- if (response.ok) {
68
- this.playback.src = this.cacheBustedURL();
69
- this.playback.hidden = false;
70
- this.recordButton.textContent = '● Retake';
71
- this.setStatus('Existing recording loaded.');
72
- } else if (response.status === 404) {
73
- this.setStatus('No recording yet.');
74
- } else {
75
- throw new Error(`Could not check recording (${response.status}).`);
76
- }
77
- } catch (error) {
78
- this.setStatus(error.message, true);
79
- }
80
- }
81
-
82
- async start(clickedAt) {
83
- const AudioContext = window.AudioContext || window.webkitAudioContext;
84
- if (!window.MediaRecorder || !navigator.mediaDevices?.getUserMedia || !AudioContext) {
85
- this.setStatus('Audio recording is not supported by this browser.', true);
86
- return;
87
- }
88
-
89
- const mimeType = 'audio/webm;codecs=opus';
90
- if (!MediaRecorder.isTypeSupported(mimeType)) {
91
- this.setStatus(`${mimeType} recording is not supported by this browser.`, true);
92
- return;
93
- }
94
-
95
- const startToken = this.#startToken = {};
96
- this.recordButton.disabled = true;
97
- this.saveButton.disabled = true;
98
- this.dataset.state = 'preparing';
99
- this.setStatus('Preparing microphone…');
100
-
101
- try {
102
- this.playback.pause();
103
- this.#audioContext = new AudioContext();
104
- await this.#audioContext.resume();
105
-
106
- if (this.#startToken !== startToken || !this.isConnected) {
107
- this.releaseMicrophone();
108
- return;
109
- }
110
-
111
- const audio = {};
112
- if (navigator.mediaDevices.getSupportedConstraints().autoGainControl) {
113
- // Preserve the microphone's dynamics for consistent offline normalization.
114
- audio.autoGainControl = false;
115
- }
116
-
117
- const mediaStream = await navigator.mediaDevices.getUserMedia({audio});
118
-
119
- if (this.#startToken !== startToken || !this.isConnected) {
120
- mediaStream.getTracks().forEach(track => track.stop());
121
- this.releaseMicrophone();
122
- return;
123
- }
124
-
125
- this.#mediaStream = mediaStream;
126
- this.#audioSource = this.#audioContext.createMediaStreamSource(mediaStream);
127
-
128
- this.#audioDelay = this.#audioContext.createDelay(RECORDING_EDGE_DELAY / 1000);
129
- this.#audioDelay.delayTime.value = RECORDING_EDGE_DELAY / 1000;
130
- this.#audioDestination = this.#audioContext.createMediaStreamDestination();
131
- this.#audioSource
132
- .connect(this.#audioDelay)
133
- .connect(this.#audioDestination);
134
-
135
- this.#chunks = [];
136
- this.#mediaRecorder = new MediaRecorder(this.#audioDestination.stream, {mimeType});
137
-
138
- this.#mediaRecorder.addEventListener('dataavailable', (event) => {
139
- if (event.data.size > 0) this.#chunks.push(event.data);
140
- });
141
-
142
- this.#mediaRecorder.onstop = () => this.finish();
143
-
144
- this.dataset.state = 'starting';
145
- this.setStatus('Starting…');
146
-
147
- // Audio reaches MediaRecorder 100 ms after it reaches the microphone. Starting
148
- // the encoder 200 ms after the click therefore retains audio beginning 100 ms
149
- // after pointer-up. If microphone setup took longer, wait only long enough for
150
- // the newly-created delay line to contain live audio.
151
- const startAt = Math.max(
152
- clickedAt + RECORDING_EDGE_DELAY * 2,
153
- performance.now() + RECORDING_EDGE_DELAY,
154
- );
155
- await new Promise(resolve => window.setTimeout(resolve, Math.max(0, startAt - performance.now())));
156
-
157
- if (this.#startToken !== startToken || !this.isConnected) return;
158
-
159
- this.#mediaRecorder.start();
160
- this.#startToken = null;
161
- this.startTimer();
162
-
163
- this.recordButton.disabled = false;
164
- this.recordButton.textContent = '■ Stop';
165
- this.dataset.state = 'recording';
166
- this.setStatus('Recording…');
167
- } catch (error) {
168
- if (this.#startToken !== startToken) return;
169
-
170
- this.#startToken = null;
171
- this.releaseMicrophone();
172
- this.recordButton.disabled = false;
173
- this.dataset.state = 'idle';
174
- this.setStatus(`Could not start recording: ${error.message}`, true);
175
- }
176
- }
177
-
178
- stop() {
179
- if (this.#mediaRecorder?.state === 'recording') {
180
- // The encoder receives audio through a 100 ms delay. Stopping immediately
181
- // excludes approximately the final 100 ms before this pointer-down event.
182
- this.#mediaRecorder.stop();
183
- this.recordButton.disabled = true;
184
- this.dataset.state = 'finishing';
185
- this.setStatus('Preparing recording…');
186
- }
187
- }
188
-
189
- finish() {
190
- this.stopTimer();
191
- this.releaseMicrophone();
192
- this.releaseRecordingURL();
193
-
194
- this.#recording = new Blob(this.#chunks, {type: 'audio/webm'});
195
- this.#recordingURL = URL.createObjectURL(this.#recording);
196
- this.playback.src = this.#recordingURL;
197
- this.playback.hidden = false;
198
-
199
- this.recordButton.disabled = false;
200
- this.recordButton.textContent = '● Retake';
201
- this.saveButton.disabled = this.#recording.size === 0;
202
- this.dataset.state = 'review';
203
- this.setStatus(this.#recording.size > 0 ? 'Review the recording, then save it.' : 'The recording was empty.', this.#recording.size === 0);
204
- }
205
-
206
- async save() {
207
- if (!this.#recording) return;
208
-
209
- this.saveButton.disabled = true;
210
- this.setStatus('Saving…');
211
-
212
- try {
213
- const response = await fetch(this.url, {
214
- method: 'PUT',
215
- headers: {'content-type': this.#recording.type},
216
- body: this.#recording,
217
- });
218
-
219
- if (!response.ok) {
220
- throw new Error((await response.text()) || `Could not save recording (${response.status}).`);
221
- }
222
-
223
- this.playback.src = this.cacheBustedURL();
224
- this.releaseRecordingURL();
225
- this.#recording = null;
226
- this.setStatus('Recording saved.');
227
- } catch (error) {
228
- this.saveButton.disabled = false;
229
- this.setStatus(`Could not save recording: ${error.message}`, true);
230
- }
231
- }
232
-
233
- startTimer() {
234
- this.#startedAt = performance.now();
235
- this.updateTimer();
236
- this.#timer = window.setInterval(() => this.updateTimer(), 250);
237
- }
238
-
239
- stopTimer() {
240
- if (this.#timer) window.clearInterval(this.#timer);
241
- this.#timer = null;
242
- }
243
-
244
- updateTimer() {
245
- const elapsed = Math.floor((performance.now() - this.#startedAt) / 1000);
246
- const minutes = Math.floor(elapsed / 60);
247
- const seconds = String(elapsed % 60).padStart(2, '0');
248
- this.time.textContent = `${minutes}:${seconds}`;
249
- }
250
-
251
- releaseMicrophone() {
252
- this.#mediaStream?.getTracks().forEach(track => track.stop());
253
- this.#mediaStream = null;
254
-
255
- this.#audioDestination?.stream.getTracks().forEach(track => track.stop());
256
- this.#audioSource?.disconnect();
257
- this.#audioDelay?.disconnect();
258
- this.#audioDestination?.disconnect();
259
- this.#audioContext?.close().catch(() => {});
260
-
261
- this.#audioContext = null;
262
- this.#audioSource = null;
263
- this.#audioDelay = null;
264
- this.#audioDestination = null;
265
- }
266
-
267
- releaseRecordingURL() {
268
- if (this.#recordingURL) URL.revokeObjectURL(this.#recordingURL);
269
- this.#recordingURL = null;
270
- }
271
-
272
- cacheBustedURL() {
273
- return `${this.url}&version=${Date.now()}`;
274
- }
275
-
276
- setStatus(message, error = false) {
277
- this.status.textContent = message;
278
- this.status.classList.toggle('error', error);
279
- }
280
- }
281
-
282
- customElements.define('presently-recorder', PresentlyRecorder);