presently 0.22.0 → 0.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bceb99c19c4563f60e51fe3e17cce836c7176ebd9b695919af5737ccc87fd4ca
4
- data.tar.gz: 102b2bbab9019d17c5aadb85c3f40044c1e94bdb2e587482c246d0c184f5fa36
3
+ metadata.gz: 38ca14929dc4ebe84fefad98f34e153f3c6423b9cbb147d73d08a84c2c64e4c2
4
+ data.tar.gz: b3efe32e93f81c0857d2109231e149d67d510a1d46c3117d0ef862b52f3a6b04
5
5
  SHA512:
6
- metadata.gz: 8edf0ac3857670ce3afe65522bdf9d560b2f909fb68a85a43161200047f6a8fe588be5bef3ee685ca007a927d47999b5afc7c0db65ae43896c78b018ec347ffc
7
- data.tar.gz: b8cca4183e4494e9fdd2923e429bc3bdca75310f127eba5474f410214c90f2cd5a0b2d89bb03df1e887673d30b81ebb45320b3af5853b73f137e6fbe6c2554f7
6
+ metadata.gz: 1104e8b67d9ff964735a644cbbedb1f9616522af673cf0903bef63cced829e61312cea822cab9cc77a3b84a812e653fb19970582f23726cb846d5f92aa594c51
7
+ data.tar.gz: d4ae5680354d4b51b1f3d6451a0cf224e03f36dd05255b8d2c9f630b63276dd420c5573b7bed72633e5377af479f1eca18856347c999c364809cae42f505cce5
checksums.yaml.gz.sig CHANGED
Binary file
@@ -91,7 +91,7 @@ module Presently
91
91
  router.get("/presenter"){make_page(resolver.root(PresenterView), interface: :presenter).call}
92
92
  router.get("/record"){make_page(resolver.root(RecordingView), interface: :recorder).call}
93
93
 
94
- router.route("/recordings", methods: ["GET", "HEAD", "PUT"]) do |request|
94
+ router.route("/recordings", methods: ["GET", "HEAD", "PUT", "PATCH"]) do |request|
95
95
  handle_recording(request, request_parameters(request))
96
96
  end
97
97
 
@@ -159,10 +159,38 @@ module Presently
159
159
  when "GET", "HEAD"
160
160
  serve_recording(request, slide, @recordings)
161
161
  when "PUT"
162
- store_recording(request, slide)
162
+ duration = recording_duration(parameters)
163
+ if parameters.key?("duration") && !duration
164
+ return invalid_recording_duration
165
+ end
166
+
167
+ store_recording(request, slide, duration: duration)
168
+ when "PATCH"
169
+ duration = recording_duration(parameters)
170
+ return invalid_recording_duration unless duration
171
+ return Protocol::HTTP::Response[404, [], ["Recording not found."]] unless @recordings.exist?(slide)
172
+
173
+ slide.update_duration!(duration)
174
+ Protocol::HTTP::Response[200, [["content-type", "application/json"]], ["{\"updated\":true,\"duration\":#{duration}}"]]
175
+ end
176
+ end
177
+
178
+ # Parse an optional positive recording duration.
179
+ # @parameter parameters [Hash] The decoded query parameters.
180
+ # @returns [Integer | Nil] The duration in seconds, or `nil` when absent or invalid.
181
+ def recording_duration(parameters)
182
+ if value = parameters["duration"]
183
+ duration = Integer(value, exception: false)
184
+ return duration if duration&.positive?
163
185
  end
164
186
  end
165
187
 
188
+ # Build the response for an invalid recording duration.
189
+ # @returns [Protocol::HTTP::Response] A bad request response.
190
+ def invalid_recording_duration
191
+ Protocol::HTTP::Response[400, [], ["Duration must be a positive number of seconds."]]
192
+ end
193
+
166
194
  # Serve normalized narration for playback, falling back to the source take.
167
195
  def handle_playback_recording(request, parameters)
168
196
  index = recording_index(parameters)
@@ -206,7 +234,7 @@ module Presently
206
234
  # @parameter request [Protocol::HTTP::Request] The incoming request.
207
235
  # @parameter slide [Slide] The slide being recorded.
208
236
  # @returns [Protocol::HTTP::Response]
209
- def store_recording(request, slide)
237
+ def store_recording(request, slide, duration: nil)
210
238
  content_type = request.headers["content-type"]&.split(";", 2)&.first
211
239
  unless content_type == Recordings::CONTENT_TYPE
212
240
  return Protocol::HTTP::Response[415, [], ["Expected #{Recordings::CONTENT_TYPE}."]]
@@ -217,7 +245,10 @@ module Presently
217
245
  end
218
246
 
219
247
  @recordings.write(slide, request.body)
220
- Protocol::HTTP::Response[201, [["content-type", "application/json"]], ["{\"saved\":true}"]]
248
+ slide.update_duration!(duration) if duration
249
+
250
+ result = duration ? "{\"saved\":true,\"duration\":#{duration}}" : "{\"saved\":true}"
251
+ Protocol::HTTP::Response[201, [["content-type", "application/json"]], [result]]
221
252
  rescue Recordings::TooLarge => error
222
253
  Protocol::HTTP::Response[413, [], [error.message]]
223
254
  end
@@ -19,6 +19,7 @@
19
19
  <script type="importmap">
20
20
  {
21
21
  "imports": {
22
+ "morphdom": "/_components/morphdom/morphdom-esm.js",
22
23
  "@socketry/presently": "/_components/@socketry/presently/Presently.js",
23
24
  "@socketry/syntax": "/_components/@socketry/syntax/Syntax.js",
24
25
  "animejs": "/_components/animejs/dist/bundles/anime.esm.min.js"
@@ -31,12 +32,17 @@
31
32
 
32
33
  <body class="playback" data-autoplay="#{self.autoplay}" data-controls="#{self.controls}">
33
34
  <main class="playback-stage">
35
+ <div id="playback-frame" class="playback-frame slide-viewport"></div>
36
+ </main>
37
+
38
+ <!-- Keep inactive slides inert while retaining their rendered HTML for local navigation. -->
39
+ <div class="playback-slides" hidden>
34
40
  <?r self.slides.each_with_index do |slide, index| ?>
35
- <div class="playback-frame slide-viewport" data-index="#{index}" data-transition="#{slide.transition}" data-duration="#{slide.duration}"<?r if index != 0 ?> hidden<?r end ?>>
41
+ <template data-index="#{index}" data-transition="#{slide.transition}" data-duration="#{slide.duration}">
36
42
  #{self.render_slide(slide)}
37
- </div>
43
+ </template>
38
44
  <?r end ?>
39
- </main>
45
+ </div>
40
46
 
41
47
  <div class="playback-audio" hidden>
42
48
  <?r self.slides.each_with_index do |slide, index| ?>
@@ -91,7 +91,8 @@ module Presently
91
91
 
92
92
  builder.tag("presently-recorder",
93
93
  id: "presently-recorder-#{index}",
94
- "data-recording-url": recording_url
94
+ "data-recording-url": recording_url,
95
+ "data-slide-duration": slide.duration
95
96
  ) do
96
97
  builder.tag(:div, class: "recording-actions") do
97
98
  builder.tag(:button, class: "recording-toggle", type: "button"){builder.text("● Record")}
@@ -101,6 +102,13 @@ module Presently
101
102
  end
102
103
 
103
104
  builder.tag(:audio, class: "recording-playback", controls: true, preload: "metadata", hidden: true){}
105
+ builder.tag(:button, class: "recording-apply-duration", type: "button", hidden: true) do
106
+ builder.text("Update Slide Duration")
107
+ end
108
+ builder.tag(:label, class: "recording-duration-option") do
109
+ builder.tag(:input, class: "recording-update-duration", type: "checkbox")
110
+ builder.tag(:span){builder.text("Update slide duration to match recording")}
111
+ end
104
112
  builder.tag(:p, class: "recording-status", role: "status"){builder.text("Checking for an existing recording…")}
105
113
  end
106
114
  end
@@ -6,6 +6,7 @@
6
6
  require "yaml"
7
7
  require "markly"
8
8
  require "protocol/url"
9
+ require "tempfile"
9
10
 
10
11
  require "markly/renderer/html"
11
12
 
@@ -353,6 +354,47 @@ module Presently
353
354
  @front_matter&.fetch("duration", 60) || 60
354
355
  end
355
356
 
357
+ # Update the expected duration in the slide's YAML front matter.
358
+ # Preserves the remainder of the source file rather than reserializing it.
359
+ # @parameter duration [Integer] The new positive duration in seconds.
360
+ # @returns [Integer] The persisted duration.
361
+ def update_duration!(duration)
362
+ duration = Integer(duration)
363
+ raise ArgumentError, "Duration must be positive!" unless duration.positive?
364
+
365
+ path = source_path
366
+ source = File.read(path)
367
+ newline = source.include?("\r\n") ? "\r\n" : "\n"
368
+
369
+ front_matter_pattern = /\A---[ \t]*(?<newline>\r?\n)(?<body>.*?)(?<closing>^---[ \t]*(?:\r?\n|\z))/m
370
+ if match = front_matter_pattern.match(source)
371
+ body = match[:body]
372
+
373
+ if line = /^duration:[^#\r\n]*(?<comment>[ \t]+#[^\r\n]*)?(?<newline>\r?\n|\z)/.match(body)
374
+ replacement = "duration: #{duration}#{line[:comment]}#{line[:newline]}"
375
+ body = body[0...line.begin(0)] + replacement + body[line.end(0)..]
376
+ else
377
+ body += match[:newline] unless body.empty? || body.end_with?("\n")
378
+ body += "duration: #{duration}#{match[:newline]}"
379
+ end
380
+
381
+ source = source[0...match.begin(:body)] + body + source[match.end(:body)..]
382
+ else
383
+ source = "---#{newline}duration: #{duration}#{newline}---#{newline}#{source}"
384
+ end
385
+
386
+ stat = File.stat(path)
387
+ Tempfile.create([".presently-slide", ".md"], File.dirname(path), binmode: true) do |file|
388
+ file.chmod(stat.mode & 0o7777)
389
+ file.write(source)
390
+ file.flush
391
+ File.rename(file.path, path)
392
+ end
393
+
394
+ (@front_matter ||= {})["duration"] = duration
395
+ return duration
396
+ end
397
+
356
398
  # The title of this slide.
357
399
  # @returns [String] The H1 text, front matter title, or filename without extension.
358
400
  def title
@@ -5,5 +5,5 @@
5
5
 
6
6
  # @namespace
7
7
  module Presently
8
- VERSION = "0.22.0"
8
+ VERSION = "0.23.0"
9
9
  end
@@ -12,15 +12,13 @@ async function prepareSlides() {
12
12
  // Owns the scripts and view transition associated with one rendering of a slide view.
13
13
  export class SlideRendering {
14
14
  #view;
15
- #html;
16
15
  #transitionName;
17
16
  #viewTransition = null;
18
17
  #slides = [];
19
18
  #disposed = false;
20
19
 
21
- constructor(view, {html = null, transition = null} = {}) {
20
+ constructor(view, {transition = null} = {}) {
22
21
  this.#view = view;
23
- this.#html = html;
24
22
  this.#transitionName = transition;
25
23
  }
26
24
 
@@ -41,27 +39,32 @@ export class SlideRendering {
41
39
  }
42
40
 
43
41
  // Update the view, initialize its slides, and emit the change event.
44
- // @parameter live [Live] The connected Live client.
42
+ // @parameter update [Function] Updates the view's DOM.
45
43
  // @returns [Promise(Boolean)] Whether the rendering was completed.
46
- async render(live) {
44
+ async render(update) {
47
45
  if (this.#disposed) return false;
48
46
 
49
47
  let initialized = false;
50
- const update = async () => {
48
+ const render = async () => {
51
49
  if (this.#disposed) return;
52
50
 
53
- live.update(this.#view.id, this.#html);
51
+ await update(this.#view);
54
52
  initialized = await this.initialize();
55
53
  };
56
54
 
57
- if (this.#transitionName && document.startViewTransition) {
55
+ if (this.#transitionName && document.startViewTransition && !document.hidden) {
58
56
  document.documentElement.dataset.transition = this.#transitionName;
59
57
  let viewTransition = null;
60
58
 
61
59
  try {
62
- viewTransition = document.startViewTransition(update);
60
+ viewTransition = document.startViewTransition(render);
63
61
  this.#viewTransition = viewTransition;
64
- await viewTransition.finished;
62
+
63
+ // A hidden document may abort the visual transition, but the DOM update should still complete.
64
+ viewTransition.ready.catch(() => {});
65
+ const finished = viewTransition.finished.catch(() => {});
66
+ await viewTransition.updateCallbackDone;
67
+ await finished;
65
68
  } finally {
66
69
  if (this.#viewTransition === viewTransition) {
67
70
  this.#viewTransition = null;
@@ -69,7 +72,7 @@ export class SlideRendering {
69
72
  }
70
73
  }
71
74
  } else {
72
- await update();
75
+ await render();
73
76
  }
74
77
 
75
78
  if (initialized && !this.#disposed) {
@@ -66,7 +66,8 @@ presently-recorder {
66
66
  gap: 0.6rem;
67
67
  }
68
68
 
69
- .recording-actions button {
69
+ .recording-actions button,
70
+ .recording-apply-duration {
70
71
  padding: 0.55rem 0.9rem;
71
72
  background: var(--surface-light);
72
73
  border: 1px solid rgba(255,255,255,0.12);
@@ -76,11 +77,17 @@ presently-recorder {
76
77
  font: inherit;
77
78
  }
78
79
 
79
- .recording-actions button:hover:not(:disabled) {
80
+ .recording-apply-duration {
81
+ justify-self: start;
82
+ }
83
+
84
+ .recording-actions button:hover:not(:disabled),
85
+ .recording-apply-duration:hover:not(:disabled) {
80
86
  background: var(--accent);
81
87
  }
82
88
 
83
- .recording-actions button:disabled {
89
+ .recording-actions button:disabled,
90
+ .recording-apply-duration:disabled {
84
91
  cursor: default;
85
92
  opacity: 0.35;
86
93
  }
@@ -129,6 +136,13 @@ presently-recorder[data-state="recording"] .recording-indicator {
129
136
  width: 100%;
130
137
  }
131
138
 
139
+ .recording-duration-option {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 0.5rem;
143
+ cursor: pointer;
144
+ }
145
+
132
146
  .recording-status {
133
147
  min-height: 1.4em;
134
148
  margin: 0;
@@ -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) => {
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
 
data/public/recorder.js CHANGED
@@ -3,7 +3,7 @@ const RECORDING_EDGE_DELAY = 100;
3
3
  // Records and reviews the narration for one slide. The element is recreated
4
4
  // when the selected slide changes, which gives each recording session a clear
5
5
  // lifetime and releases the microphone during navigation.
6
- class PresentlyRecorder extends HTMLElement {
6
+ export class PresentlyRecorder extends HTMLElement {
7
7
  #mediaRecorder = null;
8
8
  #mediaStream = null;
9
9
  #audioContext = null;
@@ -13,32 +13,46 @@ class PresentlyRecorder extends HTMLElement {
13
13
  #chunks = [];
14
14
  #recording = null;
15
15
  #recordingURL = null;
16
+ #recordingDuration = null;
16
17
  #startedAt = null;
17
18
  #timer = null;
18
19
  #startToken = null;
20
+ #ignoreClick = false;
19
21
 
20
22
  connectedCallback() {
21
23
  this.recordButton = this.querySelector('.recording-toggle');
22
24
  this.saveButton = this.querySelector('.recording-save');
23
25
  this.playback = this.querySelector('.recording-playback');
26
+ this.applyDurationButton = this.querySelector('.recording-apply-duration');
27
+ this.updateDuration = this.querySelector('.recording-update-duration');
24
28
  this.status = this.querySelector('.recording-status');
25
29
  this.time = this.querySelector('.recording-time');
26
30
 
27
31
  // Stop as soon as the pointer is pressed so the delayed audio excludes the
28
32
  // click. The click handler provides both starting and keyboard activation.
29
33
  this.recordButton.addEventListener('pointerdown', () => {
30
- if (this.#mediaRecorder?.state === 'recording') this.stop();
34
+ if (this.isRecording()) {
35
+ this.#ignoreClick = true;
36
+ this.stop();
37
+ }
31
38
  });
32
39
  this.recordButton.addEventListener('click', () => {
40
+ if (this.#ignoreClick) {
41
+ this.#ignoreClick = false;
42
+ return;
43
+ }
44
+
33
45
  if (this.recordButton.disabled) return;
34
46
 
35
- if (this.#mediaRecorder?.state === 'recording') {
47
+ if (this.isRecording()) {
36
48
  this.stop();
37
49
  } else {
38
50
  this.start(performance.now());
39
51
  }
40
52
  });
41
53
  this.saveButton.addEventListener('click', () => this.save());
54
+ this.applyDurationButton.addEventListener('click', () => this.updateSlideDuration());
55
+ this.playback.addEventListener('loadedmetadata', () => this.updateDurationAction());
42
56
 
43
57
  this.loadExisting();
44
58
  }
@@ -59,6 +73,10 @@ class PresentlyRecorder extends HTMLElement {
59
73
  get url() {
60
74
  return this.dataset.recordingUrl;
61
75
  }
76
+
77
+ isRecording() {
78
+ return this.#mediaRecorder?.state === 'recording';
79
+ }
62
80
 
63
81
  async loadExisting() {
64
82
  try {
@@ -95,6 +113,8 @@ class PresentlyRecorder extends HTMLElement {
95
113
  const startToken = this.#startToken = {};
96
114
  this.recordButton.disabled = true;
97
115
  this.saveButton.disabled = true;
116
+ this.applyDurationButton.hidden = true;
117
+ this.#recordingDuration = null;
98
118
  this.dataset.state = 'preparing';
99
119
  this.setStatus('Preparing microphone…');
100
120
 
@@ -177,6 +197,8 @@ class PresentlyRecorder extends HTMLElement {
177
197
 
178
198
  stop() {
179
199
  if (this.#mediaRecorder?.state === 'recording') {
200
+ this.#recordingDuration = (performance.now() - this.#startedAt) / 1000;
201
+
180
202
  // The encoder receives audio through a 100 ms delay. Stopping immediately
181
203
  // excludes approximately the final 100 ms before this pointer-down event.
182
204
  this.#mediaRecorder.stop();
@@ -195,6 +217,7 @@ class PresentlyRecorder extends HTMLElement {
195
217
  this.#recordingURL = URL.createObjectURL(this.#recording);
196
218
  this.playback.src = this.#recordingURL;
197
219
  this.playback.hidden = false;
220
+ this.applyDurationButton.hidden = true;
198
221
 
199
222
  this.recordButton.disabled = false;
200
223
  this.recordButton.textContent = '● Retake';
@@ -210,7 +233,19 @@ class PresentlyRecorder extends HTMLElement {
210
233
  this.setStatus('Saving…');
211
234
 
212
235
  try {
213
- const response = await fetch(this.url, {
236
+ const url = new URL(this.url, window.location.href);
237
+ let duration = null;
238
+
239
+ if (this.updateDuration.checked) {
240
+ duration = this.canonicalDuration();
241
+ if (!duration) {
242
+ throw new Error('Could not determine the recording duration.');
243
+ }
244
+
245
+ url.searchParams.set('duration', duration);
246
+ }
247
+
248
+ const response = await fetch(url, {
214
249
  method: 'PUT',
215
250
  headers: {'content-type': this.#recording.type},
216
251
  body: this.#recording,
@@ -220,16 +255,65 @@ class PresentlyRecorder extends HTMLElement {
220
255
  throw new Error((await response.text()) || `Could not save recording (${response.status}).`);
221
256
  }
222
257
 
223
- this.playback.src = this.cacheBustedURL();
224
- this.releaseRecordingURL();
258
+ // Keep the reviewed blob loaded because replacing its source resets media metadata:
225
259
  this.#recording = null;
226
- this.setStatus('Recording saved.');
260
+ if (duration) this.dataset.slideDuration = String(duration);
261
+ this.updateDurationAction();
262
+ const unit = duration === 1 ? 'second' : 'seconds';
263
+ this.setStatus(duration ? `Recording saved. Slide duration set to ${duration} ${unit}.` : 'Recording saved.');
227
264
  } catch (error) {
228
265
  this.saveButton.disabled = false;
229
266
  this.setStatus(`Could not save recording: ${error.message}`, true);
230
267
  }
231
268
  }
232
269
 
270
+ canonicalDuration() {
271
+ const duration = this.recordingDuration();
272
+ if (!Number.isFinite(duration) || duration <= 0) return null;
273
+ return Math.max(1, Math.ceil(duration));
274
+ }
275
+
276
+ updateDurationAction() {
277
+ const recordingDuration = this.canonicalDuration();
278
+ const slideDuration = Number(this.dataset.slideDuration);
279
+ this.applyDurationButton.hidden = Boolean(this.#recording) || !recordingDuration || recordingDuration === slideDuration;
280
+ }
281
+
282
+ async updateSlideDuration() {
283
+ const duration = this.canonicalDuration();
284
+ if (!duration) return;
285
+
286
+ this.applyDurationButton.disabled = true;
287
+ this.setStatus('Updating slide duration…');
288
+
289
+ try {
290
+ const url = new URL(this.url, window.location.href);
291
+ url.searchParams.set('duration', duration);
292
+ const response = await fetch(url, {method: 'PATCH'});
293
+
294
+ if (!response.ok) {
295
+ throw new Error((await response.text()) || `Could not update slide duration (${response.status}).`);
296
+ }
297
+
298
+ this.dataset.slideDuration = String(duration);
299
+ this.applyDurationButton.disabled = false;
300
+ this.applyDurationButton.hidden = true;
301
+ const unit = duration === 1 ? 'second' : 'seconds';
302
+ this.setStatus(`Slide duration set to ${duration} ${unit}.`);
303
+ } catch (error) {
304
+ this.applyDurationButton.disabled = false;
305
+ this.setStatus(`Could not update slide duration: ${error.message}`, true);
306
+ }
307
+ }
308
+
309
+ recordingDuration() {
310
+ if (Number.isFinite(this.playback.duration) && this.playback.duration > 0) {
311
+ return this.playback.duration;
312
+ }
313
+
314
+ return this.#recordingDuration;
315
+ }
316
+
233
317
  startTimer() {
234
318
  this.#startedAt = performance.now();
235
319
  this.updateTimer();
data/readme.md CHANGED
@@ -67,6 +67,14 @@ 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.23.0
71
+
72
+ - Keep narrated playback visuals synchronized when the document or inactive slide frames are hidden.
73
+ - Keep recorded audio duration visible after saving a reviewed take.
74
+ - Prevent stopping a pointer-initiated recording from immediately starting a retake.
75
+ - Update slide timing from new or existing narration recordings.
76
+ - Render audience and narrated playback slides through the same client-side lifecycle so incremental builds behave consistently.
77
+
70
78
  ### v0.22.0
71
79
 
72
80
  - Split built-in slide and interface styles into focused stylesheets while retaining `index.css` for shared defaults.
@@ -116,12 +124,6 @@ q
116
124
  - Export works from current working directory.
117
125
  - Remove explicit support for `morph` transition.
118
126
 
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
127
  ## See Also
126
128
 
127
129
  - [lively](https://github.com/socketry/lively) — The real-time application framework that powers Presently.
data/releases.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Releases
2
2
 
3
+ ## v0.23.0
4
+
5
+ - Keep narrated playback visuals synchronized when the document or inactive slide frames are hidden.
6
+ - Keep recorded audio duration visible after saving a reviewed take.
7
+ - Prevent stopping a pointer-initiated recording from immediately starting a retake.
8
+ - Update slide timing from new or existing narration recordings.
9
+ - Render audience and narrated playback slides through the same client-side lifecycle so incremental builds behave consistently.
10
+
3
11
  ## v0.22.0
4
12
 
5
13
  - 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.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
metadata.gz.sig CHANGED
Binary file