pdf-presenter 1.0.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.
- package/LICENSE +21 -0
- package/README-zhtw.md +211 -0
- package/README.md +211 -0
- package/dist/pdf-presenter.js +760 -0
- package/package.json +58 -0
- package/src/ui/audience.html +22 -0
- package/src/ui/audience.js +85 -0
- package/src/ui/modules/import-export.js +94 -0
- package/src/ui/modules/notes-editor.js +126 -0
- package/src/ui/modules/pdf-render.js +66 -0
- package/src/ui/modules/recording-dialog.js +131 -0
- package/src/ui/modules/recording.js +268 -0
- package/src/ui/modules/resizable-layout.js +146 -0
- package/src/ui/modules/timer.js +72 -0
- package/src/ui/presenter-main.js +169 -0
- package/src/ui/presenter.css +669 -0
- package/src/ui/presenter.html +142 -0
- package/src/ui/presenter.js +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pdf-presenter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight CLI that serves PDF slides in the browser with a full presenter mode (speaker notes, next slide preview, timer, audio recording).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pdf-presenter": "./dist/pdf-presenter.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"src/ui/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"README-zhtw.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsup",
|
|
18
|
+
"dev": "tsup --watch",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"prepublishOnly": "pnpm run typecheck && pnpm run build",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pdf",
|
|
26
|
+
"presenter",
|
|
27
|
+
"presentation",
|
|
28
|
+
"slides",
|
|
29
|
+
"slideshow",
|
|
30
|
+
"speaker-notes",
|
|
31
|
+
"cli"
|
|
32
|
+
],
|
|
33
|
+
"author": "Hsiehting Lin <hsieh.ting.lin@gmail.com>",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"homepage": "https://github.com/htlin222/pdf-presenter#readme",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/htlin222/pdf-presenter.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/htlin222/pdf-presenter/issues"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"commander": "^12.1.0",
|
|
48
|
+
"get-port": "^7.1.0",
|
|
49
|
+
"open": "^10.1.0",
|
|
50
|
+
"pdfjs-dist": "^4.7.76"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.7.0",
|
|
54
|
+
"tsup": "^8.3.0",
|
|
55
|
+
"typescript": "^5.6.0",
|
|
56
|
+
"vitest": "^2.1.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<title>pdf-presenter — Audience</title>
|
|
7
|
+
<link rel="stylesheet" href="/assets/presenter.css" />
|
|
8
|
+
<!--PDF_PRESENTER_CONFIG-->
|
|
9
|
+
</head>
|
|
10
|
+
<body class="audience">
|
|
11
|
+
<div id="stage" class="stage">
|
|
12
|
+
<canvas id="slide-canvas"></canvas>
|
|
13
|
+
<div id="black-overlay" class="overlay hidden"></div>
|
|
14
|
+
<div id="freeze-indicator" class="freeze-indicator hidden">FROZEN</div>
|
|
15
|
+
<div id="status" class="status"></div>
|
|
16
|
+
</div>
|
|
17
|
+
<script type="module">
|
|
18
|
+
import { initAudience } from "/assets/presenter.js";
|
|
19
|
+
initAudience();
|
|
20
|
+
</script>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Audience view entry. Loaded via presenter.js barrel from audience.html.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CHANNEL_NAME,
|
|
5
|
+
readConfig,
|
|
6
|
+
loadDocument,
|
|
7
|
+
renderPage,
|
|
8
|
+
clampSlide,
|
|
9
|
+
} from "./modules/pdf-render.js";
|
|
10
|
+
|
|
11
|
+
export async function initAudience() {
|
|
12
|
+
const config = readConfig();
|
|
13
|
+
const pdf = await loadDocument(config.pdfUrl);
|
|
14
|
+
const total = pdf.numPages;
|
|
15
|
+
const canvas = document.getElementById("slide-canvas");
|
|
16
|
+
const blackOverlay = document.getElementById("black-overlay");
|
|
17
|
+
const freezeIndicator = document.getElementById("freeze-indicator");
|
|
18
|
+
const status = document.getElementById("status");
|
|
19
|
+
|
|
20
|
+
let currentSlide = 1;
|
|
21
|
+
let frozenAt = null;
|
|
22
|
+
const channel = new BroadcastChannel(CHANNEL_NAME);
|
|
23
|
+
|
|
24
|
+
async function show(n) {
|
|
25
|
+
const slide = clampSlide(n, total);
|
|
26
|
+
if (frozenAt !== null) return;
|
|
27
|
+
currentSlide = slide;
|
|
28
|
+
await renderPage(pdf, slide, canvas);
|
|
29
|
+
if (status) status.textContent = `${slide} / ${total}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function setFrozen(frozen) {
|
|
33
|
+
if (frozen) {
|
|
34
|
+
frozenAt = currentSlide;
|
|
35
|
+
freezeIndicator.classList.remove("hidden");
|
|
36
|
+
} else {
|
|
37
|
+
frozenAt = null;
|
|
38
|
+
freezeIndicator.classList.add("hidden");
|
|
39
|
+
show(currentSlide);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setBlack(on) {
|
|
44
|
+
blackOverlay.classList.toggle("hidden", !on);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
channel.addEventListener("message", (ev) => {
|
|
48
|
+
const msg = ev.data;
|
|
49
|
+
if (!msg || typeof msg !== "object") return;
|
|
50
|
+
switch (msg.type) {
|
|
51
|
+
case "slide":
|
|
52
|
+
if (frozenAt === null) show(msg.slide);
|
|
53
|
+
break;
|
|
54
|
+
case "freeze":
|
|
55
|
+
setFrozen(!!msg.value);
|
|
56
|
+
break;
|
|
57
|
+
case "black":
|
|
58
|
+
setBlack(!!msg.value);
|
|
59
|
+
break;
|
|
60
|
+
case "hello":
|
|
61
|
+
channel.postMessage({ type: "audience-ready" });
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Keyboard nav (audience can also advance on its own if standalone).
|
|
67
|
+
window.addEventListener("keydown", (ev) => {
|
|
68
|
+
if (ev.key === "ArrowRight" || ev.key === "PageDown" || ev.key === " ") {
|
|
69
|
+
show(currentSlide + 1);
|
|
70
|
+
channel.postMessage({ type: "slide", slide: clampSlide(currentSlide, total) });
|
|
71
|
+
} else if (ev.key === "ArrowLeft" || ev.key === "PageUp") {
|
|
72
|
+
show(currentSlide - 1);
|
|
73
|
+
channel.postMessage({ type: "slide", slide: clampSlide(currentSlide, total) });
|
|
74
|
+
} else if (ev.key === "p" || ev.key === "P") {
|
|
75
|
+
window.open("/presenter", "pdf-presenter-presenter");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
window.addEventListener("resize", () => {
|
|
80
|
+
if (frozenAt === null) renderPage(pdf, currentSlide, canvas);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await show(1);
|
|
84
|
+
channel.postMessage({ type: "audience-ready" });
|
|
85
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Presenter import / export subsystem.
|
|
2
|
+
// Wires the Export and Load buttons to /notes.json and /api/notes-file.
|
|
3
|
+
// Side-effect only — no return value.
|
|
4
|
+
|
|
5
|
+
import { loadNotes } from "./pdf-render.js";
|
|
6
|
+
|
|
7
|
+
const STATUS_TIMEOUT_MS = 3000;
|
|
8
|
+
|
|
9
|
+
export function wireImportExport({
|
|
10
|
+
exportBtn,
|
|
11
|
+
loadBtn,
|
|
12
|
+
loadInput,
|
|
13
|
+
statusEl,
|
|
14
|
+
config,
|
|
15
|
+
notesCache,
|
|
16
|
+
flushPending,
|
|
17
|
+
onLoaded,
|
|
18
|
+
}) {
|
|
19
|
+
function setStatus(text, level) {
|
|
20
|
+
statusEl.classList.remove("ok", "error");
|
|
21
|
+
if (level) statusEl.classList.add(level);
|
|
22
|
+
statusEl.textContent = text || "";
|
|
23
|
+
if (text) {
|
|
24
|
+
setTimeout(() => {
|
|
25
|
+
if (statusEl.textContent === text) setStatus("");
|
|
26
|
+
}, STATUS_TIMEOUT_MS);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
exportBtn.addEventListener("click", async () => {
|
|
31
|
+
try {
|
|
32
|
+
await flushPending();
|
|
33
|
+
const res = await fetch("/notes.json");
|
|
34
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
35
|
+
const text = await res.text();
|
|
36
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
37
|
+
const url = URL.createObjectURL(blob);
|
|
38
|
+
const a = document.createElement("a");
|
|
39
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
40
|
+
a.href = url;
|
|
41
|
+
a.download = `speaker-notes-${stamp}.json`;
|
|
42
|
+
document.body.appendChild(a);
|
|
43
|
+
a.click();
|
|
44
|
+
a.remove();
|
|
45
|
+
URL.revokeObjectURL(url);
|
|
46
|
+
setStatus("Exported", "ok");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
setStatus(`Export failed: ${err.message || err}`, "error");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
loadBtn.addEventListener("click", () => loadInput.click());
|
|
53
|
+
|
|
54
|
+
loadInput.addEventListener("change", async () => {
|
|
55
|
+
const file = loadInput.files && loadInput.files[0];
|
|
56
|
+
if (!file) return;
|
|
57
|
+
try {
|
|
58
|
+
const text = await file.text();
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error("file is not valid JSON");
|
|
64
|
+
}
|
|
65
|
+
if (!parsed || typeof parsed !== "object" || !parsed.notes) {
|
|
66
|
+
throw new Error("missing 'notes' field");
|
|
67
|
+
}
|
|
68
|
+
// Flush any in-flight edit for the current slide so it isn't overwritten
|
|
69
|
+
// by the old in-memory state after reload.
|
|
70
|
+
await flushPending();
|
|
71
|
+
const res = await fetch("/api/notes-file", {
|
|
72
|
+
method: "PUT",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: text,
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const errBody = await res.json().catch(() => ({}));
|
|
78
|
+
throw new Error(errBody.error || `HTTP ${res.status}`);
|
|
79
|
+
}
|
|
80
|
+
// Refresh local cache and hand control back to the orchestrator to
|
|
81
|
+
// re-render the current slide's note in the editor.
|
|
82
|
+
const refreshed = await loadNotes(config.notesUrl);
|
|
83
|
+
const newNotes = (refreshed && refreshed.notes) || {};
|
|
84
|
+
for (const key of Object.keys(notesCache)) delete notesCache[key];
|
|
85
|
+
for (const [k, v] of Object.entries(newNotes)) notesCache[k] = v;
|
|
86
|
+
onLoaded();
|
|
87
|
+
setStatus(`Loaded (${Object.keys(newNotes).length} slides)`, "ok");
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setStatus(`Load failed: ${err.message || err}`, "error");
|
|
90
|
+
} finally {
|
|
91
|
+
loadInput.value = "";
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Presenter notes editor subsystem.
|
|
2
|
+
// Debounced auto-save to PUT /api/notes, best-effort sendBeacon on unload,
|
|
3
|
+
// and a public API that the orchestrator calls when the slide changes.
|
|
4
|
+
//
|
|
5
|
+
// The caller owns `notesCache` (plain object indexed by slide key). This
|
|
6
|
+
// module mutates it on every edit so the orchestrator's in-memory view
|
|
7
|
+
// stays in sync with what the user has typed.
|
|
8
|
+
|
|
9
|
+
const SAVE_DEBOUNCE_MS = 600;
|
|
10
|
+
|
|
11
|
+
export function createNotesEditor({
|
|
12
|
+
notesBody,
|
|
13
|
+
statusEl,
|
|
14
|
+
hintEl,
|
|
15
|
+
notesCache,
|
|
16
|
+
getCurrentSlide,
|
|
17
|
+
}) {
|
|
18
|
+
let saveTimer = null;
|
|
19
|
+
let pendingSlide = null; // slide whose note is waiting to be flushed
|
|
20
|
+
let inflightSave = Promise.resolve();
|
|
21
|
+
let suppressInput = false; // true while we programmatically set textarea value
|
|
22
|
+
|
|
23
|
+
function loadForSlide(n) {
|
|
24
|
+
const entry = notesCache[String(n)] || { hint: "", note: "" };
|
|
25
|
+
suppressInput = true;
|
|
26
|
+
notesBody.value = entry.note || "";
|
|
27
|
+
suppressInput = false;
|
|
28
|
+
hintEl.textContent = entry.hint ? `hint: ${entry.hint}` : "";
|
|
29
|
+
setStatus("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function setStatus(state) {
|
|
33
|
+
statusEl.classList.remove("saving", "saved", "error");
|
|
34
|
+
if (state === "saving") {
|
|
35
|
+
statusEl.classList.add("saving");
|
|
36
|
+
statusEl.textContent = "Saving…";
|
|
37
|
+
} else if (state === "saved") {
|
|
38
|
+
statusEl.classList.add("saved");
|
|
39
|
+
statusEl.textContent = "Saved";
|
|
40
|
+
} else if (state === "error") {
|
|
41
|
+
statusEl.classList.add("error");
|
|
42
|
+
statusEl.textContent = "Save failed";
|
|
43
|
+
} else {
|
|
44
|
+
statusEl.textContent = "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scheduleSave() {
|
|
49
|
+
if (suppressInput) return;
|
|
50
|
+
pendingSlide = getCurrentSlide();
|
|
51
|
+
// Mirror into local cache so re-entering this slide shows the draft.
|
|
52
|
+
const key = String(pendingSlide);
|
|
53
|
+
const existing = notesCache[key] || { hint: "", note: "" };
|
|
54
|
+
notesCache[key] = { hint: existing.hint || "", note: notesBody.value };
|
|
55
|
+
setStatus("saving");
|
|
56
|
+
if (saveTimer) clearTimeout(saveTimer);
|
|
57
|
+
saveTimer = setTimeout(() => {
|
|
58
|
+
saveTimer = null;
|
|
59
|
+
void persistPending();
|
|
60
|
+
}, SAVE_DEBOUNCE_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function persistPending() {
|
|
64
|
+
if (pendingSlide === null) return;
|
|
65
|
+
const slide = pendingSlide;
|
|
66
|
+
pendingSlide = null;
|
|
67
|
+
const note = notesCache[String(slide)]?.note ?? "";
|
|
68
|
+
inflightSave = inflightSave.then(async () => {
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch("/api/notes", {
|
|
71
|
+
method: "PUT",
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ slide, note }),
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
76
|
+
// Only clear the indicator if nothing new was queued meanwhile.
|
|
77
|
+
if (pendingSlide === null && !saveTimer) setStatus("saved");
|
|
78
|
+
} catch {
|
|
79
|
+
setStatus("error");
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
await inflightSave;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function flushPending() {
|
|
86
|
+
if (saveTimer) {
|
|
87
|
+
clearTimeout(saveTimer);
|
|
88
|
+
saveTimer = null;
|
|
89
|
+
await persistPending();
|
|
90
|
+
} else {
|
|
91
|
+
await inflightSave;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isFocused() {
|
|
96
|
+
return document.activeElement === notesBody;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
notesBody.addEventListener("input", scheduleSave);
|
|
100
|
+
notesBody.addEventListener("blur", () => void flushPending());
|
|
101
|
+
|
|
102
|
+
window.addEventListener("beforeunload", () => {
|
|
103
|
+
if (saveTimer) {
|
|
104
|
+
clearTimeout(saveTimer);
|
|
105
|
+
// Best-effort sync send via sendBeacon (POST-only, server accepts both
|
|
106
|
+
// PUT and POST on /api/notes for exactly this case).
|
|
107
|
+
const slide = pendingSlide;
|
|
108
|
+
if (slide !== null) {
|
|
109
|
+
const payload = JSON.stringify({
|
|
110
|
+
slide,
|
|
111
|
+
note: notesCache[String(slide)]?.note ?? "",
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
navigator.sendBeacon(
|
|
115
|
+
"/api/notes",
|
|
116
|
+
new Blob([payload], { type: "application/json" }),
|
|
117
|
+
);
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return { loadForSlide, flushPending, isFocused };
|
|
126
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared PDF.js rendering helpers used by both audience and presenter views.
|
|
2
|
+
|
|
3
|
+
import * as pdfjsLib from "/assets/pdfjs/build/pdf.mjs";
|
|
4
|
+
|
|
5
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = "/assets/pdfjs/build/pdf.worker.mjs";
|
|
6
|
+
|
|
7
|
+
export const CHANNEL_NAME = "pdf-presenter";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG = {
|
|
10
|
+
pdfUrl: "/slides.pdf",
|
|
11
|
+
notesUrl: "/notes.json",
|
|
12
|
+
timerMinutes: null,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function readConfig() {
|
|
16
|
+
const el = document.getElementById("pdf-presenter-config");
|
|
17
|
+
if (!el) return { ...DEFAULT_CONFIG };
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(el.textContent || "{}");
|
|
20
|
+
} catch {
|
|
21
|
+
return { ...DEFAULT_CONFIG };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadDocument(url) {
|
|
26
|
+
const task = pdfjsLib.getDocument({ url, isEvalSupported: false });
|
|
27
|
+
return task.promise;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadNotes(url) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url);
|
|
33
|
+
if (!res.ok) return { notes: {} };
|
|
34
|
+
return await res.json();
|
|
35
|
+
} catch {
|
|
36
|
+
return { notes: {} };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function renderPage(pdf, pageNumber, canvas) {
|
|
41
|
+
if (!canvas) return;
|
|
42
|
+
const page = await pdf.getPage(pageNumber);
|
|
43
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
44
|
+
|
|
45
|
+
const wrap = canvas.parentElement;
|
|
46
|
+
const maxW = wrap ? wrap.clientWidth : window.innerWidth;
|
|
47
|
+
const maxH = wrap ? wrap.clientHeight : window.innerHeight;
|
|
48
|
+
|
|
49
|
+
const unscaled = page.getViewport({ scale: 1 });
|
|
50
|
+
const scale = Math.min(maxW / unscaled.width, maxH / unscaled.height);
|
|
51
|
+
const viewport = page.getViewport({ scale });
|
|
52
|
+
|
|
53
|
+
canvas.width = Math.floor(viewport.width * dpr);
|
|
54
|
+
canvas.height = Math.floor(viewport.height * dpr);
|
|
55
|
+
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
|
56
|
+
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
|
57
|
+
|
|
58
|
+
const ctx = canvas.getContext("2d");
|
|
59
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
60
|
+
await page.render({ canvasContext: ctx, viewport }).promise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function clampSlide(n, total) {
|
|
64
|
+
if (total <= 0) return 1;
|
|
65
|
+
return Math.max(1, Math.min(total, n));
|
|
66
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Presenter recording save/abandon dialog.
|
|
2
|
+
// The recorder calls open(rec) after MediaRecorder stops; this module
|
|
3
|
+
// handles the modal UI and the two POST uploads (audio + metadata).
|
|
4
|
+
//
|
|
5
|
+
// rec shape: {
|
|
6
|
+
// blob: Blob,
|
|
7
|
+
// startSlide: number,
|
|
8
|
+
// endSlide: number,
|
|
9
|
+
// durationMs: number,
|
|
10
|
+
// filename: string,
|
|
11
|
+
// metaFilename: string,
|
|
12
|
+
// segments: [{slide, fromMs, toMs}, ...],
|
|
13
|
+
// metadata: object // serializable sidecar document
|
|
14
|
+
// }
|
|
15
|
+
|
|
16
|
+
function formatTimeMs(ms) {
|
|
17
|
+
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
|
18
|
+
const m = Math.floor(totalSec / 60);
|
|
19
|
+
const s = totalSec % 60;
|
|
20
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatBytes(n) {
|
|
24
|
+
if (n < 1024) return `${n} B`;
|
|
25
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
26
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createRecordingDialog({
|
|
30
|
+
dialogEl,
|
|
31
|
+
fileEl,
|
|
32
|
+
rangeEl,
|
|
33
|
+
durationEl,
|
|
34
|
+
sizeEl,
|
|
35
|
+
segmentsEl,
|
|
36
|
+
errorEl,
|
|
37
|
+
saveBtn,
|
|
38
|
+
abandonBtn,
|
|
39
|
+
}) {
|
|
40
|
+
let pending = null;
|
|
41
|
+
|
|
42
|
+
function renderSegmentList(segments) {
|
|
43
|
+
segmentsEl.innerHTML = "";
|
|
44
|
+
for (const seg of segments) {
|
|
45
|
+
const li = document.createElement("li");
|
|
46
|
+
const slide = document.createElement("span");
|
|
47
|
+
slide.className = "seg-slide";
|
|
48
|
+
slide.textContent = `p.${seg.slide}`;
|
|
49
|
+
const range = document.createElement("span");
|
|
50
|
+
range.className = "seg-range";
|
|
51
|
+
range.textContent = `${formatTimeMs(seg.fromMs)} – ${formatTimeMs(seg.toMs)}`;
|
|
52
|
+
const dur = document.createElement("span");
|
|
53
|
+
dur.className = "seg-dur";
|
|
54
|
+
dur.textContent = formatTimeMs(seg.toMs - seg.fromMs);
|
|
55
|
+
li.appendChild(slide);
|
|
56
|
+
li.appendChild(range);
|
|
57
|
+
li.appendChild(dur);
|
|
58
|
+
segmentsEl.appendChild(li);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function open(rec) {
|
|
63
|
+
pending = rec;
|
|
64
|
+
fileEl.textContent = rec.filename;
|
|
65
|
+
rangeEl.textContent =
|
|
66
|
+
rec.startSlide === rec.endSlide
|
|
67
|
+
? `slide ${rec.startSlide}`
|
|
68
|
+
: `slides ${rec.startSlide} → ${rec.endSlide}`;
|
|
69
|
+
durationEl.textContent = formatTimeMs(rec.durationMs);
|
|
70
|
+
sizeEl.textContent = formatBytes(rec.blob.size);
|
|
71
|
+
renderSegmentList(rec.segments);
|
|
72
|
+
errorEl.classList.add("hidden");
|
|
73
|
+
errorEl.textContent = "";
|
|
74
|
+
saveBtn.disabled = false;
|
|
75
|
+
abandonBtn.disabled = false;
|
|
76
|
+
dialogEl.classList.remove("hidden");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function close() {
|
|
80
|
+
dialogEl.classList.add("hidden");
|
|
81
|
+
pending = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function save() {
|
|
85
|
+
if (!pending) return;
|
|
86
|
+
saveBtn.disabled = true;
|
|
87
|
+
abandonBtn.disabled = true;
|
|
88
|
+
errorEl.classList.add("hidden");
|
|
89
|
+
try {
|
|
90
|
+
const audioRes = await fetch(
|
|
91
|
+
`/api/recording?filename=${encodeURIComponent(pending.filename)}`,
|
|
92
|
+
{
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"Content-Type": pending.blob.type || "application/octet-stream",
|
|
96
|
+
},
|
|
97
|
+
body: pending.blob,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
if (!audioRes.ok) {
|
|
101
|
+
const errBody = await audioRes.json().catch(() => ({}));
|
|
102
|
+
throw new Error(errBody.error || `audio upload failed: HTTP ${audioRes.status}`);
|
|
103
|
+
}
|
|
104
|
+
const metaRes = await fetch(
|
|
105
|
+
`/api/recording-meta?filename=${encodeURIComponent(pending.metaFilename)}`,
|
|
106
|
+
{
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify(pending.metadata),
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
if (!metaRes.ok) {
|
|
113
|
+
const errBody = await metaRes.json().catch(() => ({}));
|
|
114
|
+
throw new Error(
|
|
115
|
+
`audio saved; metadata upload failed: ${errBody.error || `HTTP ${metaRes.status}`}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
close();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
errorEl.textContent = `Save failed: ${err.message || err}`;
|
|
121
|
+
errorEl.classList.remove("hidden");
|
|
122
|
+
saveBtn.disabled = false;
|
|
123
|
+
abandonBtn.disabled = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
saveBtn.addEventListener("click", () => void save());
|
|
128
|
+
abandonBtn.addEventListener("click", close);
|
|
129
|
+
|
|
130
|
+
return { open, close };
|
|
131
|
+
}
|