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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/lib/presently/application.rb +35 -4
- data/lib/presently/playback.xrb +9 -3
- data/lib/presently/recording_view.rb +9 -1
- data/lib/presently/slide.rb +42 -0
- data/lib/presently/version.rb +1 -1
- data/public/_components/@socketry/presently/Presently/SlideRendering.js +14 -11
- data/public/_static/recorder.css +17 -3
- data/public/application.js +5 -4
- data/public/playback.js +34 -35
- data/public/recorder.js +91 -7
- data/readme.md +8 -6
- data/releases.md +8 -0
- data.tar.gz.sig +0 -0
- metadata +1 -1
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38ca14929dc4ebe84fefad98f34e153f3c6423b9cbb147d73d08a84c2c64e4c2
|
|
4
|
+
data.tar.gz: b3efe32e93f81c0857d2109231e149d67d510a1d46c3117d0ef862b52f3a6b04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/presently/playback.xrb
CHANGED
|
@@ -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
|
-
<
|
|
41
|
+
<template data-index="#{index}" data-transition="#{slide.transition}" data-duration="#{slide.duration}">
|
|
36
42
|
#{self.render_slide(slide)}
|
|
37
|
-
</
|
|
43
|
+
</template>
|
|
38
44
|
<?r end ?>
|
|
39
|
-
</
|
|
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
|
data/lib/presently/slide.rb
CHANGED
|
@@ -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
|
data/lib/presently/version.rb
CHANGED
|
@@ -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, {
|
|
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
|
|
42
|
+
// @parameter update [Function] Updates the view's DOM.
|
|
45
43
|
// @returns [Promise(Boolean)] Whether the rendering was completed.
|
|
46
|
-
async render(
|
|
44
|
+
async render(update) {
|
|
47
45
|
if (this.#disposed) return false;
|
|
48
46
|
|
|
49
47
|
let initialized = false;
|
|
50
|
-
const
|
|
48
|
+
const render = async () => {
|
|
51
49
|
if (this.#disposed) return;
|
|
52
50
|
|
|
53
|
-
|
|
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(
|
|
60
|
+
viewTransition = document.startViewTransition(render);
|
|
63
61
|
this.#viewTransition = viewTransition;
|
|
64
|
-
|
|
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
|
|
75
|
+
await render();
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
if (initialized && !this.#disposed) {
|
data/public/_static/recorder.css
CHANGED
|
@@ -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-
|
|
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;
|
data/public/application.js
CHANGED
|
@@ -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) => {
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
224
|
-
this.releaseRecordingURL();
|
|
258
|
+
// Keep the reviewed blob loaded because replacing its source resets media metadata:
|
|
225
259
|
this.#recording = null;
|
|
226
|
-
this.
|
|
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
metadata.gz.sig
CHANGED
|
Binary file
|