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.
@@ -0,0 +1,268 @@
1
+ // Presenter audio recording subsystem.
2
+ //
3
+ // Wraps MediaRecorder, tracks a slide-change timeline so the user can tell
4
+ // which slide was on screen during which audio range, and hands finished
5
+ // recordings to a createRecordingDialog instance for save/abandon.
6
+ //
7
+ // Public API: { onSlideChanged(newSlide) } — orchestrator calls this from
8
+ // its show() function whenever the presenter advances or rewinds.
9
+
10
+ const TICK_INTERVAL_MS = 250;
11
+
12
+ const MIME_CANDIDATES = [
13
+ "audio/webm;codecs=opus",
14
+ "audio/webm",
15
+ "audio/ogg;codecs=opus",
16
+ "audio/mp4",
17
+ "audio/mpeg",
18
+ ];
19
+
20
+ function pickRecorderMime() {
21
+ if (typeof MediaRecorder === "undefined") return null;
22
+ for (const c of MIME_CANDIDATES) {
23
+ try {
24
+ if (MediaRecorder.isTypeSupported(c)) return c;
25
+ } catch {
26
+ /* ignore */
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function formatTimeMs(ms) {
33
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
34
+ const m = Math.floor(totalSec / 60);
35
+ const s = totalSec % 60;
36
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
37
+ }
38
+
39
+ function timestampNow() {
40
+ const d = new Date();
41
+ const p = (n) => String(n).padStart(2, "0");
42
+ return (
43
+ d.getFullYear().toString() +
44
+ p(d.getMonth() + 1) +
45
+ p(d.getDate()) +
46
+ p(d.getHours()) +
47
+ p(d.getMinutes()) +
48
+ p(d.getSeconds())
49
+ );
50
+ }
51
+
52
+ export function createRecorder({
53
+ config,
54
+ getCurrentSlide,
55
+ dialog,
56
+ startBtn,
57
+ pauseBtn,
58
+ stopBtn,
59
+ elapsedEl,
60
+ labelEl,
61
+ indicatorEl,
62
+ }) {
63
+ let mediaRecorder = null;
64
+ let recordedChunks = [];
65
+ let startSlide = null;
66
+ let startedAtIso = null;
67
+ let elapsedMs = 0;
68
+ let lastTickAt = 0;
69
+ let tickHandle = null;
70
+ // Segment timeline: each item is {slide, fromMs, toMs?}.
71
+ // The last entry is the open segment (no toMs until closed).
72
+ let segments = [];
73
+
74
+ function pdfBaseName() {
75
+ const name = config.pdfName || "slides.pdf";
76
+ return name.replace(/\.pdf$/i, "");
77
+ }
78
+
79
+ function buildRecordingFilename(s, e) {
80
+ // Honour the user-requested .mp3 naming convention regardless of the
81
+ // actual container produced by MediaRecorder — browsers cannot emit MP3
82
+ // natively. See README for details on transcoding if needed.
83
+ return `${pdfBaseName()}_${s}_to_${e}_at_${timestampNow()}.mp3`;
84
+ }
85
+
86
+ function currentElapsedMsPrecise() {
87
+ if (tickHandle !== null) {
88
+ return elapsedMs + (Date.now() - lastTickAt);
89
+ }
90
+ return elapsedMs;
91
+ }
92
+
93
+ function closeOpenSegment(at) {
94
+ const open = segments[segments.length - 1];
95
+ if (open && open.toMs === undefined) open.toMs = at;
96
+ }
97
+
98
+ function onSlideChanged(newSlide) {
99
+ // Only track slide changes while actively recording — paused time is
100
+ // excluded so paused-nav doesn't pollute the timeline.
101
+ if (!mediaRecorder || mediaRecorder.state !== "recording") return;
102
+ const open = segments[segments.length - 1];
103
+ if (!open || open.slide === newSlide) return;
104
+ const at = currentElapsedMsPrecise();
105
+ open.toMs = at;
106
+ segments.push({ slide: newSlide, fromMs: at });
107
+ }
108
+
109
+ function buildMetadata(rec) {
110
+ return {
111
+ audio: rec.filename,
112
+ pdf: config.pdfName || null,
113
+ startedAt: startedAtIso,
114
+ durationMs: rec.durationMs,
115
+ duration: formatTimeMs(rec.durationMs),
116
+ mimeType: rec.blob.type || "",
117
+ segments: rec.segments.map((seg) => ({
118
+ slide: seg.slide,
119
+ fromMs: seg.fromMs,
120
+ toMs: seg.toMs,
121
+ from: formatTimeMs(seg.fromMs),
122
+ to: formatTimeMs(seg.toMs),
123
+ })),
124
+ };
125
+ }
126
+
127
+ function updateUI(state) {
128
+ if (state === "recording") {
129
+ startBtn.disabled = true;
130
+ pauseBtn.disabled = false;
131
+ pauseBtn.textContent = "Pause";
132
+ stopBtn.disabled = false;
133
+ indicatorEl.classList.add("active");
134
+ indicatorEl.classList.remove("paused");
135
+ labelEl.textContent = "Recording";
136
+ } else if (state === "paused") {
137
+ startBtn.disabled = true;
138
+ pauseBtn.disabled = false;
139
+ pauseBtn.textContent = "Resume";
140
+ stopBtn.disabled = false;
141
+ indicatorEl.classList.remove("active");
142
+ indicatorEl.classList.add("paused");
143
+ labelEl.textContent = "Paused";
144
+ } else {
145
+ startBtn.disabled = false;
146
+ pauseBtn.disabled = true;
147
+ pauseBtn.textContent = "Pause";
148
+ stopBtn.disabled = true;
149
+ indicatorEl.classList.remove("active", "paused");
150
+ labelEl.textContent = "Audio";
151
+ elapsedEl.textContent = "00:00";
152
+ }
153
+ }
154
+
155
+ function startTicker() {
156
+ lastTickAt = Date.now();
157
+ if (tickHandle !== null) return;
158
+ tickHandle = setInterval(() => {
159
+ const now = Date.now();
160
+ elapsedMs += now - lastTickAt;
161
+ lastTickAt = now;
162
+ elapsedEl.textContent = formatTimeMs(elapsedMs);
163
+ }, TICK_INTERVAL_MS);
164
+ }
165
+
166
+ function stopTicker() {
167
+ if (tickHandle !== null) {
168
+ clearInterval(tickHandle);
169
+ tickHandle = null;
170
+ }
171
+ }
172
+
173
+ async function start() {
174
+ if (mediaRecorder) return;
175
+ let stream;
176
+ try {
177
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
178
+ } catch (err) {
179
+ labelEl.textContent = "Mic denied";
180
+ console.error("Microphone access denied:", err);
181
+ return;
182
+ }
183
+ const mime = pickRecorderMime();
184
+ try {
185
+ mediaRecorder = mime
186
+ ? new MediaRecorder(stream, { mimeType: mime })
187
+ : new MediaRecorder(stream);
188
+ } catch (err) {
189
+ stream.getTracks().forEach((t) => t.stop());
190
+ labelEl.textContent = "Unsupported";
191
+ console.error("MediaRecorder init failed:", err);
192
+ return;
193
+ }
194
+ recordedChunks = [];
195
+ elapsedMs = 0;
196
+ startSlide = getCurrentSlide();
197
+ startedAtIso = new Date().toISOString();
198
+ segments = [{ slide: startSlide, fromMs: 0 }];
199
+
200
+ mediaRecorder.addEventListener("dataavailable", (ev) => {
201
+ if (ev.data && ev.data.size > 0) recordedChunks.push(ev.data);
202
+ });
203
+ mediaRecorder.addEventListener("stop", () => {
204
+ stream.getTracks().forEach((t) => t.stop());
205
+ const finalMs = currentElapsedMsPrecise();
206
+ stopTicker();
207
+ closeOpenSegment(finalMs);
208
+ const endSlide = getCurrentSlide();
209
+ const blob = new Blob(recordedChunks, {
210
+ type: mediaRecorder.mimeType || mime || "audio/webm",
211
+ });
212
+ const filename = buildRecordingFilename(startSlide, endSlide);
213
+ const pending = {
214
+ blob,
215
+ startSlide,
216
+ endSlide,
217
+ durationMs: finalMs,
218
+ filename,
219
+ metaFilename: filename.replace(/\.[^./\\]+$/, "") + ".meta.json",
220
+ segments: segments.slice(),
221
+ };
222
+ pending.metadata = buildMetadata(pending);
223
+ mediaRecorder = null;
224
+ recordedChunks = [];
225
+ segments = [];
226
+ updateUI("idle");
227
+ dialog.open(pending);
228
+ });
229
+
230
+ mediaRecorder.start(1000);
231
+ updateUI("recording");
232
+ startTicker();
233
+ }
234
+
235
+ function togglePause() {
236
+ if (!mediaRecorder) return;
237
+ if (mediaRecorder.state === "recording") {
238
+ // Close the open segment at the pause boundary so toMs reflects audio
239
+ // time up to the pause; we'll re-open a fresh segment on resume.
240
+ const at = currentElapsedMsPrecise();
241
+ closeOpenSegment(at);
242
+ mediaRecorder.pause();
243
+ stopTicker();
244
+ updateUI("paused");
245
+ } else if (mediaRecorder.state === "paused") {
246
+ mediaRecorder.resume();
247
+ startTicker();
248
+ // Re-open a segment on whichever slide is currently on screen — if the
249
+ // presenter navigated during the pause, this captures the new slide.
250
+ const at = currentElapsedMsPrecise();
251
+ segments.push({ slide: getCurrentSlide(), fromMs: at });
252
+ updateUI("recording");
253
+ }
254
+ }
255
+
256
+ function stop() {
257
+ if (!mediaRecorder) return;
258
+ if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
259
+ }
260
+
261
+ startBtn.addEventListener("click", () => void start());
262
+ pauseBtn.addEventListener("click", togglePause);
263
+ stopBtn.addEventListener("click", stop);
264
+
265
+ updateUI("idle");
266
+
267
+ return { onSlideChanged };
268
+ }
@@ -0,0 +1,146 @@
1
+ // Draggable split handles for the presenter layout.
2
+ //
3
+ // Three independent splits:
4
+ // --col-left : on layoutEl — width of the left column
5
+ // --row-top-left : on leftColEl — height of the current-slide row
6
+ // inside the left column
7
+ // --row-top-right : on rightColEl — height of the next-slide row
8
+ // inside the right column
9
+ //
10
+ // Each is clamped to [MIN_PCT, MAX_PCT] so no pane can fully collapse,
11
+ // and persisted to localStorage so the split survives refreshes.
12
+
13
+ const STORAGE_KEY = "pdf-presenter-layout";
14
+ const MIN_PCT = 20;
15
+ const MAX_PCT = 85;
16
+ const DEFAULT_COL_LEFT_PCT = 66;
17
+ const DEFAULT_ROW_TOP_PCT = 60;
18
+
19
+ function clampPct(n) {
20
+ if (!Number.isFinite(n)) return null;
21
+ return Math.max(MIN_PCT, Math.min(MAX_PCT, n));
22
+ }
23
+
24
+ function loadPersisted() {
25
+ try {
26
+ const raw = localStorage.getItem(STORAGE_KEY);
27
+ if (!raw) return null;
28
+ const parsed = JSON.parse(raw);
29
+ if (!parsed || typeof parsed !== "object") return null;
30
+ const col = clampPct(Number(parsed.colLeftPct));
31
+ // Migrate an older single `rowTopPct` value into both left and right.
32
+ const legacyRow = clampPct(Number(parsed.rowTopPct));
33
+ const left = clampPct(Number(parsed.rowTopLeftPct));
34
+ const right = clampPct(Number(parsed.rowTopRightPct));
35
+ return {
36
+ colLeftPct: col ?? DEFAULT_COL_LEFT_PCT,
37
+ rowTopLeftPct: left ?? legacyRow ?? DEFAULT_ROW_TOP_PCT,
38
+ rowTopRightPct: right ?? legacyRow ?? DEFAULT_ROW_TOP_PCT,
39
+ };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export function createResizableLayout({
46
+ layoutEl,
47
+ colDividerEl,
48
+ leftColEl,
49
+ rowDividerLeftEl,
50
+ rightColEl,
51
+ rowDividerRightEl,
52
+ onResize,
53
+ }) {
54
+ const persisted = loadPersisted();
55
+ let colLeftPct = persisted?.colLeftPct ?? DEFAULT_COL_LEFT_PCT;
56
+ let rowTopLeftPct = persisted?.rowTopLeftPct ?? DEFAULT_ROW_TOP_PCT;
57
+ let rowTopRightPct = persisted?.rowTopRightPct ?? DEFAULT_ROW_TOP_PCT;
58
+
59
+ function apply() {
60
+ layoutEl.style.setProperty("--col-left", `${colLeftPct}%`);
61
+ leftColEl.style.setProperty("--row-top-left", `${rowTopLeftPct}%`);
62
+ rightColEl.style.setProperty("--row-top-right", `${rowTopRightPct}%`);
63
+ }
64
+
65
+ function persist() {
66
+ try {
67
+ localStorage.setItem(
68
+ STORAGE_KEY,
69
+ JSON.stringify({ colLeftPct, rowTopLeftPct, rowTopRightPct }),
70
+ );
71
+ } catch {
72
+ /* storage disabled / quota — ignore */
73
+ }
74
+ }
75
+
76
+ // Axis-agnostic drag: picks clientX/rect.width for "col", clientY/rect.height
77
+ // for "row". `containerEl` is the element whose bounding box we measure —
78
+ // for the col divider that's the whole layout, for a row divider that's
79
+ // its own column container.
80
+ function attachDrag({ dividerEl, containerEl, axis, get, set }) {
81
+ dividerEl.addEventListener("mousedown", (ev) => {
82
+ ev.preventDefault();
83
+ const rect = containerEl.getBoundingClientRect();
84
+ const startValue = get();
85
+ const startX = ev.clientX;
86
+ const startY = ev.clientY;
87
+ dividerEl.classList.add("dragging");
88
+ const prevCursor = document.body.style.cursor;
89
+ const prevSelect = document.body.style.userSelect;
90
+ document.body.style.cursor = axis === "col" ? "col-resize" : "row-resize";
91
+ document.body.style.userSelect = "none";
92
+
93
+ function onMove(mev) {
94
+ const next =
95
+ axis === "col"
96
+ ? clampPct(startValue + ((mev.clientX - startX) / rect.width) * 100)
97
+ : clampPct(startValue + ((mev.clientY - startY) / rect.height) * 100);
98
+ if (next !== null) {
99
+ set(next);
100
+ apply();
101
+ }
102
+ }
103
+ function onUp() {
104
+ window.removeEventListener("mousemove", onMove);
105
+ window.removeEventListener("mouseup", onUp);
106
+ dividerEl.classList.remove("dragging");
107
+ document.body.style.cursor = prevCursor;
108
+ document.body.style.userSelect = prevSelect;
109
+ persist();
110
+ if (typeof onResize === "function") onResize();
111
+ }
112
+ window.addEventListener("mousemove", onMove);
113
+ window.addEventListener("mouseup", onUp);
114
+ });
115
+ }
116
+
117
+ attachDrag({
118
+ dividerEl: colDividerEl,
119
+ containerEl: layoutEl,
120
+ axis: "col",
121
+ get: () => colLeftPct,
122
+ set: (v) => {
123
+ colLeftPct = v;
124
+ },
125
+ });
126
+ attachDrag({
127
+ dividerEl: rowDividerLeftEl,
128
+ containerEl: leftColEl,
129
+ axis: "row",
130
+ get: () => rowTopLeftPct,
131
+ set: (v) => {
132
+ rowTopLeftPct = v;
133
+ },
134
+ });
135
+ attachDrag({
136
+ dividerEl: rowDividerRightEl,
137
+ containerEl: rightColEl,
138
+ axis: "row",
139
+ get: () => rowTopRightPct,
140
+ set: (v) => {
141
+ rowTopRightPct = v;
142
+ },
143
+ });
144
+
145
+ apply();
146
+ }
@@ -0,0 +1,72 @@
1
+ // Presenter timer subsystem. Supports count-up and count-down (with warn/danger
2
+ // colour bands), click-to-pause on the timer element itself, and a separate
3
+ // reset button. All state is private to the factory closure.
4
+
5
+ const TICK_INTERVAL_MS = 250;
6
+
7
+ export function createTimer({ timerEl, resetBtnEl, countdownMs }) {
8
+ let startedAt = Date.now();
9
+ let pausedAt = null; // ms timestamp when paused, null while running
10
+
11
+ function elapsed() {
12
+ const base = pausedAt !== null ? pausedAt : Date.now();
13
+ return base - startedAt;
14
+ }
15
+
16
+ function reset() {
17
+ startedAt = Date.now();
18
+ // Reset leaves pause state untouched — a reset while paused starts
19
+ // the next run-cycle from 00:00 but still paused.
20
+ if (pausedAt !== null) pausedAt = startedAt;
21
+ tick();
22
+ }
23
+
24
+ function togglePause() {
25
+ if (pausedAt === null) {
26
+ pausedAt = Date.now();
27
+ } else {
28
+ // Shift startedAt forward by the pause duration so elapsed continues
29
+ // from where we left off.
30
+ startedAt += Date.now() - pausedAt;
31
+ pausedAt = null;
32
+ }
33
+ tick();
34
+ }
35
+
36
+ function formatMs(ms) {
37
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
38
+ const m = Math.floor(totalSec / 60);
39
+ const s = totalSec % 60;
40
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
41
+ }
42
+
43
+ function tick() {
44
+ const ms = elapsed();
45
+ if (countdownMs !== null) {
46
+ const remaining = countdownMs - ms;
47
+ timerEl.textContent = formatMs(remaining >= 0 ? remaining : -remaining);
48
+ timerEl.classList.remove("warn", "danger");
49
+ if (remaining <= 60 * 1000) timerEl.classList.add("danger");
50
+ else if (remaining <= 5 * 60 * 1000) timerEl.classList.add("warn");
51
+ } else {
52
+ timerEl.textContent = formatMs(ms);
53
+ }
54
+ timerEl.classList.toggle("paused", pausedAt !== null);
55
+ }
56
+
57
+ timerEl.addEventListener("click", (ev) => {
58
+ ev.preventDefault();
59
+ togglePause();
60
+ timerEl.blur();
61
+ });
62
+ resetBtnEl.addEventListener("click", (ev) => {
63
+ ev.preventDefault();
64
+ reset();
65
+ resetBtnEl.blur();
66
+ });
67
+
68
+ setInterval(tick, TICK_INTERVAL_MS);
69
+ tick();
70
+
71
+ return { reset, togglePause };
72
+ }
@@ -0,0 +1,169 @@
1
+ // Presenter view entry. Loaded via presenter.js barrel from presenter.html.
2
+ // This file is a thin orchestrator — all subsystem logic lives under
3
+ // ./modules/ and is composed here via factory functions.
4
+
5
+ import {
6
+ CHANNEL_NAME,
7
+ readConfig,
8
+ loadDocument,
9
+ loadNotes,
10
+ renderPage,
11
+ clampSlide,
12
+ } from "./modules/pdf-render.js";
13
+ import { createTimer } from "./modules/timer.js";
14
+ import { createNotesEditor } from "./modules/notes-editor.js";
15
+ import { wireImportExport } from "./modules/import-export.js";
16
+ import { createRecordingDialog } from "./modules/recording-dialog.js";
17
+ import { createRecorder } from "./modules/recording.js";
18
+ import { createResizableLayout } from "./modules/resizable-layout.js";
19
+
20
+ export async function initPresenter() {
21
+ const config = readConfig();
22
+ const pdf = await loadDocument(config.pdfUrl);
23
+ const total = pdf.numPages;
24
+ const notesFile = await loadNotes(config.notesUrl);
25
+ const notes = (notesFile && notesFile.notes) || {};
26
+
27
+ const currentCanvas = document.getElementById("current-canvas");
28
+ const nextCanvas = document.getElementById("next-canvas");
29
+ const notesBody = document.getElementById("notes-body");
30
+ const notesStatus = document.getElementById("notes-status");
31
+ const notesHint = document.getElementById("notes-hint");
32
+ const counter = document.getElementById("counter");
33
+ const timerEl = document.getElementById("timer");
34
+
35
+ const channel = new BroadcastChannel(CHANNEL_NAME);
36
+ let currentSlide = 1;
37
+ let frozen = false;
38
+ let blackedOut = false;
39
+
40
+ const editor = createNotesEditor({
41
+ notesBody,
42
+ statusEl: notesStatus,
43
+ hintEl: notesHint,
44
+ notesCache: notes,
45
+ getCurrentSlide: () => currentSlide,
46
+ });
47
+
48
+ async function show(n) {
49
+ const target = clampSlide(n, total);
50
+ const slideChanged = target !== currentSlide;
51
+ await editor.flushPending();
52
+ currentSlide = target;
53
+ counter.textContent = `${currentSlide} / ${total}`;
54
+ editor.loadForSlide(currentSlide);
55
+ if (slideChanged) recorder.onSlideChanged(currentSlide);
56
+ await Promise.all([
57
+ renderPage(pdf, currentSlide, currentCanvas),
58
+ currentSlide < total
59
+ ? renderPage(pdf, currentSlide + 1, nextCanvas)
60
+ : clearNext(),
61
+ ]);
62
+ channel.postMessage({ type: "slide", slide: currentSlide });
63
+ }
64
+
65
+ function clearNext() {
66
+ const ctx = nextCanvas.getContext("2d");
67
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
68
+ ctx.clearRect(0, 0, nextCanvas.width, nextCanvas.height);
69
+ }
70
+
71
+ // ---- Import / export ----
72
+ wireImportExport({
73
+ exportBtn: document.getElementById("export-notes"),
74
+ loadBtn: document.getElementById("load-notes"),
75
+ loadInput: document.getElementById("load-notes-input"),
76
+ statusEl: document.getElementById("notes-action-status"),
77
+ config,
78
+ notesCache: notes,
79
+ flushPending: editor.flushPending,
80
+ onLoaded: () => editor.loadForSlide(currentSlide),
81
+ });
82
+
83
+ // ---- Audio recording ----
84
+ const dialog = createRecordingDialog({
85
+ dialogEl: document.getElementById("record-dialog"),
86
+ fileEl: document.getElementById("record-dialog-file"),
87
+ rangeEl: document.getElementById("record-dialog-range"),
88
+ durationEl: document.getElementById("record-dialog-duration"),
89
+ sizeEl: document.getElementById("record-dialog-size"),
90
+ segmentsEl: document.getElementById("record-dialog-segments"),
91
+ errorEl: document.getElementById("record-dialog-error"),
92
+ saveBtn: document.getElementById("record-dialog-save"),
93
+ abandonBtn: document.getElementById("record-dialog-abandon"),
94
+ });
95
+
96
+ const recorder = createRecorder({
97
+ config,
98
+ getCurrentSlide: () => currentSlide,
99
+ dialog,
100
+ startBtn: document.getElementById("record-start"),
101
+ pauseBtn: document.getElementById("record-pause"),
102
+ stopBtn: document.getElementById("record-stop"),
103
+ elapsedEl: document.getElementById("record-elapsed"),
104
+ labelEl: document.getElementById("record-label"),
105
+ indicatorEl: document.getElementById("record-indicator"),
106
+ });
107
+
108
+ function toggleFreeze() {
109
+ frozen = !frozen;
110
+ channel.postMessage({ type: "freeze", value: frozen });
111
+ }
112
+
113
+ function toggleBlack() {
114
+ blackedOut = !blackedOut;
115
+ channel.postMessage({ type: "black", value: blackedOut });
116
+ }
117
+
118
+ window.addEventListener("keydown", (ev) => {
119
+ // When the notes editor is focused, let keys behave normally — but give
120
+ // Escape as an explicit way to leave the editor and return to slide nav.
121
+ if (editor.isFocused()) {
122
+ if (ev.key === "Escape") {
123
+ ev.preventDefault();
124
+ notesBody.blur();
125
+ }
126
+ return;
127
+ }
128
+ if (ev.key === "ArrowRight" || ev.key === "PageDown" || ev.key === " ") {
129
+ ev.preventDefault();
130
+ show(currentSlide + 1);
131
+ } else if (ev.key === "ArrowLeft" || ev.key === "PageUp") {
132
+ ev.preventDefault();
133
+ show(currentSlide - 1);
134
+ } else if (ev.key === "Home") {
135
+ show(1);
136
+ } else if (ev.key === "End") {
137
+ show(total);
138
+ } else if (ev.key === "f" || ev.key === "F") {
139
+ toggleFreeze();
140
+ } else if (ev.key === "b" || ev.key === "B") {
141
+ toggleBlack();
142
+ } else if (ev.key === "r" || ev.key === "R") {
143
+ timer.reset();
144
+ }
145
+ });
146
+
147
+ window.addEventListener("resize", () => show(currentSlide));
148
+
149
+ // ---- Timer ----
150
+ const timerResetBtn = document.getElementById("timer-reset");
151
+ const countdownMs =
152
+ typeof config.timerMinutes === "number" && config.timerMinutes > 0
153
+ ? config.timerMinutes * 60 * 1000
154
+ : null;
155
+ const timer = createTimer({ timerEl, resetBtnEl: timerResetBtn, countdownMs });
156
+
157
+ // ---- Resizable layout dividers ----
158
+ createResizableLayout({
159
+ layoutEl: document.querySelector(".layout"),
160
+ colDividerEl: document.getElementById("divider-col"),
161
+ leftColEl: document.querySelector(".col-left"),
162
+ rowDividerLeftEl: document.getElementById("divider-row-left"),
163
+ rightColEl: document.querySelector(".col-right"),
164
+ rowDividerRightEl: document.getElementById("divider-row-right"),
165
+ onResize: () => show(currentSlide),
166
+ });
167
+
168
+ await show(1);
169
+ }