@honeydeck/honeydeck 0.6.0 → 0.7.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/docs/mobile.md +1 -1
- package/docs/presenter-mode.md +12 -6
- package/package.json +1 -1
- package/src/runtime/Deck.tsx +18 -1
- package/src/runtime/presentationApi.ts +112 -6
- package/src/runtime/sync.ts +130 -12
- package/src/runtime/views/PresenterCastButton.tsx +17 -9
- package/src/runtime/views/PresenterView.tsx +247 -30
- package/src/runtime/views/SPEC.md +28 -5
- package/src/runtime/views/presenterTime.ts +15 -0
package/docs/mobile.md
CHANGED
|
@@ -13,7 +13,7 @@ Honeydeck works on phones and tablets, but mobile presentation mode behaves diff
|
|
|
13
13
|
| Text selection | Slide text can be selected normally | Slide text selection is off by default; use the nav bar toggle when you need it |
|
|
14
14
|
| Zoom | Browser/page zoom or normal browser controls | Honeydeck-controlled slide zoom with pinch, up to `5x` |
|
|
15
15
|
| Overview | Keyboard selection and mouse clicks | Responsive fixed two-column grid |
|
|
16
|
-
| Presenter mode | Current slide, next preview, notes, clock | Current slide, notes, navigation buttons; no next preview |
|
|
16
|
+
| Presenter mode | Current slide, next preview, notes, clock, timer, actions; no navigation buttons | Current slide, notes, navigation buttons; no next preview |
|
|
17
17
|
|
|
18
18
|
## Tap zones
|
|
19
19
|
|
package/docs/presenter-mode.md
CHANGED
|
@@ -20,7 +20,7 @@ Presenter mode shows the presenter everything they need while the audience sees
|
|
|
20
20
|
│ - Remember to demo the sparkle button │
|
|
21
21
|
│ - Mention PDF export │
|
|
22
22
|
│ │
|
|
23
|
-
│ Slide 3/12 · Step 2/4
|
|
23
|
+
│ Slide 3/12 · Step 2/4 12:34 Timer 1:23 [Open] │
|
|
24
24
|
└──────────────────────────────────────────┘
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -29,10 +29,12 @@ Includes:
|
|
|
29
29
|
- Next timeline-state preview (smaller): the next step on the current slide when one exists, otherwise the next slide at step 0
|
|
30
30
|
- Speaker notes for current slide
|
|
31
31
|
- Slide number / step counter
|
|
32
|
-
- Wall clock
|
|
32
|
+
- Wall clock and an elapsed presentation timer with start, pause, continue, restart, and close/reset controls
|
|
33
33
|
- Button to open audience view in a new tab/window
|
|
34
|
-
-
|
|
35
|
-
-
|
|
34
|
+
- Color mode toggle that also updates audience views and cast receivers
|
|
35
|
+
- Blank screen toggle (`b`) that makes audience and cast views black while presenter mode shows a `Screen blanked (b)` indicator
|
|
36
|
+
- Button to cast the audience view to a secondary display when supported; the same control becomes a stop button while casting. Unsupported browsers show a visibly disabled-looking button with hover/accessible text explaining why casting is unavailable.
|
|
37
|
+
- Navigation buttons that move through the timeline while staying in presenter mode on mobile. Desktop presenter mode uses keyboard shortcuts instead.
|
|
36
38
|
|
|
37
39
|
## Speaker Notes
|
|
38
40
|
|
|
@@ -65,7 +67,7 @@ Content here.
|
|
|
65
67
|
- Navigation controls button in normal presentation.
|
|
66
68
|
- Direct URL: `/#/presenter/1/0`
|
|
67
69
|
|
|
68
|
-
Pressing `p` opens presenter mode in the **current tab**.
|
|
70
|
+
Pressing `p` opens presenter mode in the **current tab**. In presenter mode, press `p` or `Escape` to return to the audience slide view at the same slide and step.
|
|
69
71
|
|
|
70
72
|
## Navigation
|
|
71
73
|
|
|
@@ -86,12 +88,16 @@ Reveal content from later timeline steps is also shown in the `Next` preview at
|
|
|
86
88
|
|
|
87
89
|
When no next timeline state exists, the `Next` preview shows an end-of-deck placeholder instead of trying to render a missing slide.
|
|
88
90
|
|
|
91
|
+
## Blank Screen
|
|
92
|
+
|
|
93
|
+
Press `b` or use the blank screen button to make the audience and cast views black. Press `b` again or use the same button to restore the slide. Audience tabs unblank automatically when presenter mode disconnects.
|
|
94
|
+
|
|
89
95
|
## Presenter/Audience Sync
|
|
90
96
|
|
|
91
97
|
Presenter mode and audience view synchronize navigation via `BroadcastChannel` and, when supported, the Presentation API.
|
|
92
98
|
|
|
93
99
|
- Presenter mode acts as the controller.
|
|
94
|
-
- Audience view (opened from presenter mode) listens for navigation updates.
|
|
100
|
+
- Audience view (opened from presenter mode) listens for navigation, color mode, and blank-screen updates.
|
|
95
101
|
- The cast audience sends a `sync-request` as soon as the receiver connection appears; the presenter replies with the current slide/step in a `sync-response` so late connections resync immediately.
|
|
96
102
|
- The cast audience follows presenter navigation through Presentation API receiver messages.
|
|
97
103
|
- No server, internet, WebSocket, or device pairing required.
|
package/package.json
CHANGED
package/src/runtime/Deck.tsx
CHANGED
|
@@ -156,12 +156,22 @@ export function Deck() {
|
|
|
156
156
|
}, [route]);
|
|
157
157
|
|
|
158
158
|
// ── Audience sync: BroadcastChannel + Presentation API receiver ──────
|
|
159
|
+
const [blankScreen, setBlankScreen] = useState<"black" | null>(null);
|
|
160
|
+
const handleBlankScreen = useCallback(
|
|
161
|
+
(mode: "black" | "off") =>
|
|
162
|
+
setBlankScreen(mode === "black" ? "black" : null),
|
|
163
|
+
[],
|
|
164
|
+
);
|
|
159
165
|
useSync({
|
|
160
166
|
enabled: route.view === "slide" || route.view === "overview",
|
|
161
167
|
isPresenter: false,
|
|
168
|
+
onSetColorMode: setColorMode,
|
|
169
|
+
onBlankScreen: handleBlankScreen,
|
|
162
170
|
});
|
|
163
171
|
usePresentationReceiverSync({
|
|
164
172
|
enabled: route.view === "slide" || route.view === "overview",
|
|
173
|
+
onSetColorMode: setColorMode,
|
|
174
|
+
onBlankScreen: handleBlankScreen,
|
|
165
175
|
});
|
|
166
176
|
|
|
167
177
|
const resetZoom = useCallback(() => {
|
|
@@ -250,7 +260,9 @@ export function Deck() {
|
|
|
250
260
|
|
|
251
261
|
// ── Presenter mode: delegate to PresenterView ──────────────────────────
|
|
252
262
|
if (route.view === "presenter") {
|
|
253
|
-
return
|
|
263
|
+
return (
|
|
264
|
+
<PresenterView colorMode={colorMode} onSetColorMode={setColorMode} />
|
|
265
|
+
);
|
|
254
266
|
}
|
|
255
267
|
|
|
256
268
|
// Whether slide transitions are enabled (can be disabled via deck frontmatter)
|
|
@@ -394,6 +406,11 @@ export function Deck() {
|
|
|
394
406
|
/>
|
|
395
407
|
)}
|
|
396
408
|
|
|
409
|
+
{/* ── Black screen overlay (controlled by presenter) ────────── */}
|
|
410
|
+
{blankScreen === "black" && (
|
|
411
|
+
<div className="fixed inset-0 bg-black z-[100]" aria-hidden="true" />
|
|
412
|
+
)}
|
|
413
|
+
|
|
397
414
|
{/* ── Navigation bar ────────────────────────────────────────────── */}
|
|
398
415
|
{/* GAP-06: showSlideNumbers wired from config */}
|
|
399
416
|
{!isOverview && (
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
|
|
2
3
|
import { getRouteUrl } from "./navigation.ts";
|
|
3
4
|
import { navigate, parseHash, type Route } from "./router.ts";
|
|
4
5
|
import {
|
|
6
|
+
createSyncColorModeMessage,
|
|
5
7
|
createSyncResponseMessage,
|
|
6
8
|
resolveAudienceRouteFromSyncMessage,
|
|
9
|
+
type SyncBlankScreenMessage,
|
|
10
|
+
type SyncColorModeMessage,
|
|
7
11
|
type SyncMessage,
|
|
8
12
|
type SyncNavigateMessage,
|
|
9
13
|
type SyncRequestMessage,
|
|
@@ -60,6 +64,10 @@ type PresenterRoute = {
|
|
|
60
64
|
step: number;
|
|
61
65
|
};
|
|
62
66
|
|
|
67
|
+
type PresentationColorModeRef = {
|
|
68
|
+
current: ColorMode;
|
|
69
|
+
};
|
|
70
|
+
|
|
63
71
|
type PresentationWindowLike = Window & {
|
|
64
72
|
PresentationRequest?: PresentationRequestLike;
|
|
65
73
|
navigator: Navigator & {
|
|
@@ -142,13 +150,21 @@ function sendPresentationSyncResponse(
|
|
|
142
150
|
connection: PresentationConnectionLike | null,
|
|
143
151
|
slide: number,
|
|
144
152
|
step: number,
|
|
153
|
+
colorMode?: ColorMode,
|
|
145
154
|
): void {
|
|
146
155
|
sendPresentationMessage(
|
|
147
156
|
connection,
|
|
148
|
-
createSyncResponseMessage({ slide, step }),
|
|
157
|
+
createSyncResponseMessage({ slide, step }, colorMode),
|
|
149
158
|
);
|
|
150
159
|
}
|
|
151
160
|
|
|
161
|
+
function sendPresentationColorModeToConnection(
|
|
162
|
+
connection: PresentationConnectionLike | null,
|
|
163
|
+
colorMode: ColorMode,
|
|
164
|
+
): void {
|
|
165
|
+
sendPresentationMessage(connection, createSyncColorModeMessage(colorMode));
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
type PresentationRouteRef = {
|
|
153
169
|
current: PresenterRoute;
|
|
154
170
|
};
|
|
@@ -163,7 +179,9 @@ export async function startPresentationCast({
|
|
|
163
179
|
audienceUrl,
|
|
164
180
|
currentSlide,
|
|
165
181
|
currentStep,
|
|
182
|
+
currentColorMode,
|
|
166
183
|
routeRef,
|
|
184
|
+
colorModeRef,
|
|
167
185
|
requestConstructor,
|
|
168
186
|
connectionRef,
|
|
169
187
|
startInFlightRef,
|
|
@@ -175,7 +193,9 @@ export async function startPresentationCast({
|
|
|
175
193
|
audienceUrl: string | null;
|
|
176
194
|
currentSlide: number;
|
|
177
195
|
currentStep: number;
|
|
196
|
+
currentColorMode: ColorMode;
|
|
178
197
|
routeRef: PresentationRouteRef;
|
|
198
|
+
colorModeRef: PresentationColorModeRef;
|
|
179
199
|
requestConstructor: PresentationRequestLike | null;
|
|
180
200
|
connectionRef: { current: PresentationConnectionLike | null };
|
|
181
201
|
startInFlightRef: { current: boolean };
|
|
@@ -212,6 +232,7 @@ export async function startPresentationCast({
|
|
|
212
232
|
connection,
|
|
213
233
|
routeRef.current.slide,
|
|
214
234
|
routeRef.current.step,
|
|
235
|
+
colorModeRef.current,
|
|
215
236
|
);
|
|
216
237
|
};
|
|
217
238
|
|
|
@@ -235,6 +256,7 @@ export async function startPresentationCast({
|
|
|
235
256
|
connection.addEventListener?.("statechange", onStateChange);
|
|
236
257
|
|
|
237
258
|
sendPresentationRouteToConnection(connection, currentSlide, currentStep);
|
|
259
|
+
sendPresentationColorModeToConnection(connection, currentColorMode);
|
|
238
260
|
} catch {
|
|
239
261
|
if (castGeneration !== castGenerationRef.current) {
|
|
240
262
|
return;
|
|
@@ -265,8 +287,12 @@ function getConnectionFromEvent(event: {
|
|
|
265
287
|
|
|
266
288
|
export function usePresentationReceiverSync({
|
|
267
289
|
enabled = true,
|
|
290
|
+
onSetColorMode,
|
|
291
|
+
onBlankScreen,
|
|
268
292
|
}: {
|
|
269
293
|
enabled?: boolean;
|
|
294
|
+
onSetColorMode?: (mode: ColorMode) => void;
|
|
295
|
+
onBlankScreen?: (mode: "black" | "off") => void;
|
|
270
296
|
}): void {
|
|
271
297
|
useEffect(() => {
|
|
272
298
|
if (!enabled) return;
|
|
@@ -283,6 +309,20 @@ export function usePresentationReceiverSync({
|
|
|
283
309
|
function handleMessage(event: MessageEvent<unknown>) {
|
|
284
310
|
const message = parsePresentationMessage(event.data);
|
|
285
311
|
if (cancelled || !isSyncMessage(message)) return;
|
|
312
|
+
|
|
313
|
+
if (isColorModeMessage(message)) {
|
|
314
|
+
onSetColorMode?.(message.colorMode);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (isBlankScreenMessage(message)) {
|
|
319
|
+
onBlankScreen?.(message.mode);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (message.type === "sync-response" && message.colorMode) {
|
|
324
|
+
onSetColorMode?.(message.colorMode);
|
|
325
|
+
}
|
|
286
326
|
const currentRoute = parseHash(location.hash);
|
|
287
327
|
const nextRoute = resolveAudienceRouteFromSyncMessage(
|
|
288
328
|
currentRoute,
|
|
@@ -342,7 +382,7 @@ export function usePresentationReceiverSync({
|
|
|
342
382
|
});
|
|
343
383
|
connections.clear();
|
|
344
384
|
};
|
|
345
|
-
}, [enabled]);
|
|
385
|
+
}, [enabled, onSetColorMode, onBlankScreen]);
|
|
346
386
|
}
|
|
347
387
|
|
|
348
388
|
export function usePresentationCast({
|
|
@@ -350,16 +390,19 @@ export function usePresentationCast({
|
|
|
350
390
|
audienceUrl,
|
|
351
391
|
currentSlide,
|
|
352
392
|
currentStep,
|
|
393
|
+
currentColorMode,
|
|
353
394
|
}: {
|
|
354
395
|
enabled?: boolean;
|
|
355
396
|
audienceUrl: string | null;
|
|
356
397
|
currentSlide: number;
|
|
357
398
|
currentStep: number;
|
|
399
|
+
currentColorMode: ColorMode;
|
|
358
400
|
}): {
|
|
359
401
|
supported: boolean;
|
|
360
402
|
isCasting: boolean;
|
|
361
403
|
startCasting: () => Promise<void>;
|
|
362
404
|
stopCasting: () => void;
|
|
405
|
+
sendMessage: (message: SyncMessage) => void;
|
|
363
406
|
} {
|
|
364
407
|
const [isCasting, setIsCasting] = useState(false);
|
|
365
408
|
const isMountedRef = useRef(true);
|
|
@@ -370,6 +413,7 @@ export function usePresentationCast({
|
|
|
370
413
|
slide: currentSlide,
|
|
371
414
|
step: currentStep,
|
|
372
415
|
});
|
|
416
|
+
const colorModeRef = useRef<ColorMode>(currentColorMode);
|
|
373
417
|
const supported = isPresentationApiSupported();
|
|
374
418
|
|
|
375
419
|
useEffect(() => {
|
|
@@ -379,6 +423,10 @@ export function usePresentationCast({
|
|
|
379
423
|
};
|
|
380
424
|
}, [currentSlide, currentStep]);
|
|
381
425
|
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
colorModeRef.current = currentColorMode;
|
|
428
|
+
}, [currentColorMode]);
|
|
429
|
+
|
|
382
430
|
const setCastingState = useCallback((next: boolean) => {
|
|
383
431
|
if (isMountedRef.current) setIsCasting(next);
|
|
384
432
|
}, []);
|
|
@@ -394,7 +442,9 @@ export function usePresentationCast({
|
|
|
394
442
|
audienceUrl,
|
|
395
443
|
currentSlide,
|
|
396
444
|
currentStep,
|
|
445
|
+
currentColorMode,
|
|
397
446
|
routeRef,
|
|
447
|
+
colorModeRef,
|
|
398
448
|
requestConstructor: getPresentationRequestConstructor(),
|
|
399
449
|
connectionRef,
|
|
400
450
|
startInFlightRef,
|
|
@@ -405,6 +455,7 @@ export function usePresentationCast({
|
|
|
405
455
|
audienceUrl,
|
|
406
456
|
currentSlide,
|
|
407
457
|
currentStep,
|
|
458
|
+
currentColorMode,
|
|
408
459
|
enabled,
|
|
409
460
|
supported,
|
|
410
461
|
setCastingState,
|
|
@@ -419,6 +470,14 @@ export function usePresentationCast({
|
|
|
419
470
|
);
|
|
420
471
|
}, [currentSlide, currentStep, isCasting]);
|
|
421
472
|
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (!isCasting) return;
|
|
475
|
+
sendPresentationColorModeToConnection(
|
|
476
|
+
connectionRef.current,
|
|
477
|
+
currentColorMode,
|
|
478
|
+
);
|
|
479
|
+
}, [currentColorMode, isCasting]);
|
|
480
|
+
|
|
422
481
|
useEffect(() => {
|
|
423
482
|
isMountedRef.current = true;
|
|
424
483
|
return () => {
|
|
@@ -427,9 +486,13 @@ export function usePresentationCast({
|
|
|
427
486
|
};
|
|
428
487
|
}, [stopCasting]);
|
|
429
488
|
|
|
489
|
+
const sendMessage = useCallback((message: SyncMessage) => {
|
|
490
|
+
sendPresentationMessage(connectionRef.current, message);
|
|
491
|
+
}, []);
|
|
492
|
+
|
|
430
493
|
return useMemo(
|
|
431
|
-
() => ({ supported, isCasting, startCasting, stopCasting }),
|
|
432
|
-
[supported, isCasting, startCasting, stopCasting],
|
|
494
|
+
() => ({ supported, isCasting, startCasting, stopCasting, sendMessage }),
|
|
495
|
+
[supported, isCasting, startCasting, stopCasting, sendMessage],
|
|
433
496
|
);
|
|
434
497
|
}
|
|
435
498
|
|
|
@@ -441,9 +504,52 @@ function isSyncRequestMessage(value: unknown): value is SyncRequestMessage {
|
|
|
441
504
|
|
|
442
505
|
function isSyncMessage(
|
|
443
506
|
value: unknown,
|
|
444
|
-
): value is
|
|
507
|
+
): value is
|
|
508
|
+
| SyncNavigateMessage
|
|
509
|
+
| SyncResponseMessage
|
|
510
|
+
| SyncColorModeMessage
|
|
511
|
+
| SyncBlankScreenMessage {
|
|
445
512
|
if (typeof value !== "object" || value === null) return false;
|
|
446
513
|
if (!("type" in value)) return false;
|
|
447
514
|
const type = (value as SyncMessage).type;
|
|
448
|
-
|
|
515
|
+
if (type === "color-mode") return isColorModeMessage(value);
|
|
516
|
+
if (type === "blank-screen") return isBlankScreenMessage(value);
|
|
517
|
+
if (type !== "navigate" && type !== "sync-response") return false;
|
|
518
|
+
|
|
519
|
+
const slide = (value as { slide?: unknown }).slide;
|
|
520
|
+
const step = (value as { step?: unknown }).step;
|
|
521
|
+
if (
|
|
522
|
+
typeof slide !== "number" ||
|
|
523
|
+
!Number.isFinite(slide) ||
|
|
524
|
+
typeof step !== "number" ||
|
|
525
|
+
!Number.isFinite(step)
|
|
526
|
+
) {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const colorMode = (value as { colorMode?: unknown }).colorMode;
|
|
531
|
+
return colorMode === undefined || isColorMode(colorMode);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function isColorModeMessage(value: unknown): value is SyncColorModeMessage {
|
|
535
|
+
if (typeof value !== "object" || value === null) return false;
|
|
536
|
+
if (!("type" in value)) return false;
|
|
537
|
+
return (
|
|
538
|
+
(value as SyncMessage).type === "color-mode" &&
|
|
539
|
+
isColorMode((value as { colorMode?: unknown }).colorMode)
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isBlankScreenMessage(value: unknown): value is SyncBlankScreenMessage {
|
|
544
|
+
if (typeof value !== "object" || value === null) return false;
|
|
545
|
+
if (!("type" in value)) return false;
|
|
546
|
+
const mode = (value as { mode?: unknown }).mode;
|
|
547
|
+
return (
|
|
548
|
+
(value as SyncMessage).type === "blank-screen" &&
|
|
549
|
+
(mode === "black" || mode === "off")
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function isColorMode(value: unknown): value is ColorMode {
|
|
554
|
+
return value === "system" || value === "light" || value === "dark";
|
|
449
555
|
}
|
package/src/runtime/sync.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* ### Message types
|
|
11
11
|
* - `navigate` — presenter changed slide/step
|
|
12
|
+
* - `color-mode` — presenter changed configured color mode
|
|
13
|
+
* - `blank-screen` — presenter toggled audience blank screen
|
|
12
14
|
* - `sync-request` — audience asks for the current presenter route
|
|
13
15
|
* - `sync-response` — presenter replies with the current route
|
|
14
16
|
* - `presenter-connected` — a presenter window opened
|
|
@@ -28,7 +30,8 @@
|
|
|
28
30
|
* ```
|
|
29
31
|
*/
|
|
30
32
|
|
|
31
|
-
import { useEffect, useRef, useState } from "react";
|
|
33
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
34
|
+
import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
|
|
32
35
|
import { navigate, parseHash, type Route } from "./router.ts";
|
|
33
36
|
|
|
34
37
|
// ---------------------------------------------------------------------------
|
|
@@ -49,6 +52,17 @@ export type SyncResponseMessage = {
|
|
|
49
52
|
type: "sync-response";
|
|
50
53
|
slide: number;
|
|
51
54
|
step: number;
|
|
55
|
+
colorMode?: ColorMode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type SyncColorModeMessage = {
|
|
59
|
+
type: "color-mode";
|
|
60
|
+
colorMode: ColorMode;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type SyncBlankScreenMessage = {
|
|
64
|
+
type: "blank-screen";
|
|
65
|
+
mode: "black" | "off";
|
|
52
66
|
};
|
|
53
67
|
|
|
54
68
|
export type SyncPresenceMessage =
|
|
@@ -59,6 +73,8 @@ export type SyncMessage =
|
|
|
59
73
|
| SyncNavigateMessage
|
|
60
74
|
| SyncRequestMessage
|
|
61
75
|
| SyncResponseMessage
|
|
76
|
+
| SyncColorModeMessage
|
|
77
|
+
| SyncBlankScreenMessage
|
|
62
78
|
| SyncPresenceMessage;
|
|
63
79
|
|
|
64
80
|
export type UseSyncOptions = {
|
|
@@ -70,6 +86,12 @@ export type UseSyncOptions = {
|
|
|
70
86
|
currentSlide?: number;
|
|
71
87
|
/** Current 0-based step index (required when `isPresenter: true`). */
|
|
72
88
|
currentStep?: number;
|
|
89
|
+
/** Current configured color mode, broadcast by presenter when supplied. */
|
|
90
|
+
currentColorMode?: ColorMode;
|
|
91
|
+
/** Called by audience windows when presenter color mode changes. */
|
|
92
|
+
onSetColorMode?: (mode: ColorMode) => void;
|
|
93
|
+
/** Called by audience windows when presenter toggles blank screen. */
|
|
94
|
+
onBlankScreen?: (mode: "black" | "off") => void;
|
|
73
95
|
};
|
|
74
96
|
|
|
75
97
|
type PresenterRoute = {
|
|
@@ -89,14 +111,28 @@ export function createSyncRequestMessage(): SyncRequestMessage {
|
|
|
89
111
|
|
|
90
112
|
export function createSyncResponseMessage(
|
|
91
113
|
route: PresenterRoute,
|
|
114
|
+
colorMode?: ColorMode,
|
|
92
115
|
): SyncResponseMessage {
|
|
93
116
|
return {
|
|
94
117
|
type: "sync-response",
|
|
95
118
|
slide: route.slide,
|
|
96
119
|
step: route.step,
|
|
120
|
+
...(colorMode ? { colorMode } : {}),
|
|
97
121
|
};
|
|
98
122
|
}
|
|
99
123
|
|
|
124
|
+
export function createSyncColorModeMessage(
|
|
125
|
+
colorMode: ColorMode,
|
|
126
|
+
): SyncColorModeMessage {
|
|
127
|
+
return { type: "color-mode", colorMode };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createSyncBlankScreenMessage(
|
|
131
|
+
mode: "black" | "off",
|
|
132
|
+
): SyncBlankScreenMessage {
|
|
133
|
+
return { type: "blank-screen", mode };
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
export function resolveAudienceRouteFromSyncMessage(
|
|
101
137
|
currentRoute: Route,
|
|
102
138
|
message: SyncNavigateMessage | SyncResponseMessage,
|
|
@@ -110,6 +146,14 @@ export function resolveAudienceRouteFromSyncMessage(
|
|
|
110
146
|
};
|
|
111
147
|
}
|
|
112
148
|
|
|
149
|
+
function isColorModeValue(value: unknown): value is { colorMode: ColorMode } {
|
|
150
|
+
if (typeof value !== "object" || value === null) return false;
|
|
151
|
+
const colorMode = (value as { colorMode?: unknown }).colorMode;
|
|
152
|
+
return (
|
|
153
|
+
colorMode === "system" || colorMode === "light" || colorMode === "dark"
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
function isSyncMessage(value: unknown): value is SyncMessage {
|
|
114
158
|
if (typeof value !== "object" || value === null) return false;
|
|
115
159
|
if (!("type" in value)) return false;
|
|
@@ -118,17 +162,32 @@ function isSyncMessage(value: unknown): value is SyncMessage {
|
|
|
118
162
|
if (type === "sync-request") return true;
|
|
119
163
|
if (type === "presenter-connected") return true;
|
|
120
164
|
if (type === "presenter-disconnected") return true;
|
|
165
|
+
if (type === "color-mode") return isColorModeValue(value);
|
|
166
|
+
if (type === "blank-screen") return isBlankScreenValue(value);
|
|
121
167
|
|
|
122
168
|
if (type !== "navigate" && type !== "sync-response") return false;
|
|
123
169
|
|
|
124
170
|
const slide = (value as { slide?: unknown }).slide;
|
|
125
171
|
const step = (value as { step?: unknown }).step;
|
|
126
|
-
|
|
127
|
-
typeof slide
|
|
128
|
-
Number.isFinite(slide)
|
|
129
|
-
typeof step
|
|
130
|
-
Number.isFinite(step)
|
|
131
|
-
)
|
|
172
|
+
if (
|
|
173
|
+
typeof slide !== "number" ||
|
|
174
|
+
!Number.isFinite(slide) ||
|
|
175
|
+
typeof step !== "number" ||
|
|
176
|
+
!Number.isFinite(step)
|
|
177
|
+
) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const colorMode = (value as { colorMode?: unknown }).colorMode;
|
|
182
|
+
return colorMode === undefined || isColorModeValue({ colorMode });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isBlankScreenValue(
|
|
186
|
+
value: unknown,
|
|
187
|
+
): value is { mode: "black" | "off" } {
|
|
188
|
+
if (typeof value !== "object" || value === null) return false;
|
|
189
|
+
const mode = (value as { mode?: unknown }).mode;
|
|
190
|
+
return mode === "black" || mode === "off";
|
|
132
191
|
}
|
|
133
192
|
|
|
134
193
|
function isRouteSyncMessage(
|
|
@@ -137,6 +196,18 @@ function isRouteSyncMessage(
|
|
|
137
196
|
return message.type === "navigate" || message.type === "sync-response";
|
|
138
197
|
}
|
|
139
198
|
|
|
199
|
+
function isColorModeSyncMessage(
|
|
200
|
+
message: SyncMessage,
|
|
201
|
+
): message is SyncColorModeMessage {
|
|
202
|
+
return message.type === "color-mode";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isBlankScreenSyncMessage(
|
|
206
|
+
message: SyncMessage,
|
|
207
|
+
): message is SyncBlankScreenMessage {
|
|
208
|
+
return message.type === "blank-screen";
|
|
209
|
+
}
|
|
210
|
+
|
|
140
211
|
// ---------------------------------------------------------------------------
|
|
141
212
|
// Hook
|
|
142
213
|
// ---------------------------------------------------------------------------
|
|
@@ -144,21 +215,31 @@ function isRouteSyncMessage(
|
|
|
144
215
|
/**
|
|
145
216
|
* Bidirectional sync hook.
|
|
146
217
|
*
|
|
147
|
-
* @returns `{ presenterConnected }` —
|
|
148
|
-
* detected that a presenter window is open on
|
|
218
|
+
* @returns `{ presenterConnected, broadcastBlankScreen }` — presenterConnected is
|
|
219
|
+
* true when an audience window has detected that a presenter window is open on
|
|
220
|
+
* the same channel. broadcastBlankScreen sends a blank-screen command.
|
|
149
221
|
*/
|
|
150
222
|
export function useSync({
|
|
151
223
|
enabled = true,
|
|
152
224
|
isPresenter,
|
|
153
225
|
currentSlide,
|
|
154
226
|
currentStep,
|
|
155
|
-
|
|
227
|
+
currentColorMode,
|
|
228
|
+
onSetColorMode,
|
|
229
|
+
onBlankScreen,
|
|
230
|
+
}: UseSyncOptions): {
|
|
231
|
+
presenterConnected: boolean;
|
|
232
|
+
broadcastBlankScreen: (mode: "black" | "off") => void;
|
|
233
|
+
} {
|
|
156
234
|
const [presenterConnected, setPresenterConnected] = useState(false);
|
|
157
235
|
const channelRef = useRef<BroadcastChannel | null>(null);
|
|
158
236
|
const presenterRouteRef = useRef<PresenterRoute>({
|
|
159
237
|
slide: currentSlide ?? 1,
|
|
160
238
|
step: currentStep ?? 0,
|
|
161
239
|
});
|
|
240
|
+
const presenterColorModeRef = useRef<ColorMode | undefined>(currentColorMode);
|
|
241
|
+
const onBlankScreenRef = useRef(onBlankScreen);
|
|
242
|
+
onBlankScreenRef.current = onBlankScreen;
|
|
162
243
|
|
|
163
244
|
useEffect(() => {
|
|
164
245
|
if (!isPresenter) return;
|
|
@@ -170,6 +251,11 @@ export function useSync({
|
|
|
170
251
|
};
|
|
171
252
|
}, [currentSlide, currentStep, isPresenter]);
|
|
172
253
|
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!isPresenter) return;
|
|
256
|
+
presenterColorModeRef.current = currentColorMode;
|
|
257
|
+
}, [currentColorMode, isPresenter]);
|
|
258
|
+
|
|
173
259
|
// ── Channel lifecycle ───────────────────────────────────────────────────
|
|
174
260
|
|
|
175
261
|
useEffect(() => {
|
|
@@ -199,6 +285,7 @@ export function useSync({
|
|
|
199
285
|
channel.postMessage(
|
|
200
286
|
createSyncResponseMessage(
|
|
201
287
|
presenterRouteRef.current,
|
|
288
|
+
presenterColorModeRef.current,
|
|
202
289
|
) satisfies SyncMessage,
|
|
203
290
|
);
|
|
204
291
|
}
|
|
@@ -207,6 +294,7 @@ export function useSync({
|
|
|
207
294
|
|
|
208
295
|
if (msg.type === "presenter-disconnected") {
|
|
209
296
|
setPresenterConnected(false);
|
|
297
|
+
onBlankScreenRef.current?.("off");
|
|
210
298
|
return;
|
|
211
299
|
}
|
|
212
300
|
|
|
@@ -215,8 +303,23 @@ export function useSync({
|
|
|
215
303
|
return;
|
|
216
304
|
}
|
|
217
305
|
|
|
306
|
+
if (isColorModeSyncMessage(msg)) {
|
|
307
|
+
setPresenterConnected(true);
|
|
308
|
+
onSetColorMode?.(msg.colorMode);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (isBlankScreenSyncMessage(msg)) {
|
|
313
|
+
setPresenterConnected(true);
|
|
314
|
+
onBlankScreenRef.current?.(msg.mode);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
218
318
|
if (isRouteSyncMessage(msg)) {
|
|
219
319
|
setPresenterConnected(true);
|
|
320
|
+
if (msg.type === "sync-response" && msg.colorMode) {
|
|
321
|
+
onSetColorMode?.(msg.colorMode);
|
|
322
|
+
}
|
|
220
323
|
const currentRoute = parseHash(location.hash);
|
|
221
324
|
const nextRoute = resolveAudienceRouteFromSyncMessage(
|
|
222
325
|
currentRoute,
|
|
@@ -241,7 +344,7 @@ export function useSync({
|
|
|
241
344
|
channel.close();
|
|
242
345
|
channelRef.current = null;
|
|
243
346
|
};
|
|
244
|
-
}, [enabled, isPresenter]);
|
|
347
|
+
}, [enabled, isPresenter, onSetColorMode]);
|
|
245
348
|
|
|
246
349
|
// ── Presenter: broadcast navigation changes ─────────────────────────────
|
|
247
350
|
|
|
@@ -263,5 +366,20 @@ export function useSync({
|
|
|
263
366
|
} satisfies SyncMessage);
|
|
264
367
|
}, [enabled, isPresenter, currentSlide, currentStep]);
|
|
265
368
|
|
|
266
|
-
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (!enabled || !isPresenter || !currentColorMode) return;
|
|
371
|
+
|
|
372
|
+
presenterColorModeRef.current = currentColorMode;
|
|
373
|
+
channelRef.current?.postMessage(
|
|
374
|
+
createSyncColorModeMessage(currentColorMode) satisfies SyncMessage,
|
|
375
|
+
);
|
|
376
|
+
}, [enabled, isPresenter, currentColorMode]);
|
|
377
|
+
|
|
378
|
+
const broadcastBlankScreen = useCallback((mode: "black" | "off") => {
|
|
379
|
+
channelRef.current?.postMessage(
|
|
380
|
+
createSyncBlankScreenMessage(mode) satisfies SyncMessage,
|
|
381
|
+
);
|
|
382
|
+
}, []);
|
|
383
|
+
|
|
384
|
+
return { presenterConnected, broadcastBlankScreen };
|
|
267
385
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { CastIcon, StopCircleIcon } from "lucide-react";
|
|
2
2
|
|
|
3
|
+
const UNSUPPORTED_HINT =
|
|
4
|
+
"Presentation casting is not supported in this browser";
|
|
5
|
+
|
|
3
6
|
export function PresenterCastButton({
|
|
4
7
|
supported,
|
|
5
8
|
isCasting,
|
|
@@ -12,20 +15,25 @@ export function PresenterCastButton({
|
|
|
12
15
|
onStopCasting: () => void;
|
|
13
16
|
}) {
|
|
14
17
|
const label = isCasting ? "Stop casting" : "Cast audience view";
|
|
15
|
-
const
|
|
16
|
-
|
|
18
|
+
const title = supported ? label : UNSUPPORTED_HINT;
|
|
19
|
+
|
|
20
|
+
function handleClick() {
|
|
21
|
+
if (!supported) return;
|
|
22
|
+
if (isCasting) onStopCasting();
|
|
23
|
+
else void onStartCasting();
|
|
24
|
+
}
|
|
17
25
|
|
|
18
26
|
return (
|
|
19
27
|
<button
|
|
20
28
|
type="button"
|
|
21
|
-
onClick={
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
aria-
|
|
25
|
-
className={`px-3 py-1 rounded border
|
|
29
|
+
onClick={handleClick}
|
|
30
|
+
title={title}
|
|
31
|
+
aria-label={title}
|
|
32
|
+
aria-disabled={!supported}
|
|
33
|
+
className={`px-3 py-1 rounded border text-sm font-[inherit] inline-flex items-center gap-1.5 transition-[background,color,border-color,opacity] ${
|
|
26
34
|
supported
|
|
27
|
-
? "bg-white/6"
|
|
28
|
-
: "
|
|
35
|
+
? "border-white/20 bg-white/6 text-white/80 hover:bg-white/10"
|
|
36
|
+
: "border-white/10 bg-white/3 text-white/25 opacity-60 grayscale cursor-not-allowed"
|
|
29
37
|
}`}
|
|
30
38
|
>
|
|
31
39
|
{label}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* │ │ (large) │ ├──────────┤ │
|
|
12
12
|
* │ │ │ │ Notes │ │
|
|
13
13
|
* │ └──────────────────────┘ └──────────┘ │
|
|
14
|
-
* │ Slide 3/12 · Step 2/4
|
|
14
|
+
* │ Slide 3/12 · Step 2/4 12:34 Timer 1:23 [Open] │
|
|
15
15
|
* └──────────────────────────────────────────┘
|
|
16
16
|
*
|
|
17
17
|
* ### Notes collection
|
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
ChevronRightIcon,
|
|
36
36
|
ChevronUpIcon,
|
|
37
37
|
ExternalLinkIcon,
|
|
38
|
+
MonitorOffIcon,
|
|
39
|
+
PauseIcon,
|
|
40
|
+
PlayIcon,
|
|
41
|
+
RotateCcwIcon,
|
|
42
|
+
XIcon,
|
|
38
43
|
} from "lucide-react";
|
|
39
44
|
import {
|
|
40
45
|
type ReactNode,
|
|
@@ -44,8 +49,14 @@ import {
|
|
|
44
49
|
useRef,
|
|
45
50
|
useState,
|
|
46
51
|
} from "react";
|
|
52
|
+
import {
|
|
53
|
+
type ColorMode,
|
|
54
|
+
ColorModeCycleButton,
|
|
55
|
+
} from "../components/ColorModeCycleButton.tsx";
|
|
47
56
|
import { NotesContext } from "../components/Notes.tsx";
|
|
48
57
|
import {
|
|
58
|
+
getSlideRouteFromRoute,
|
|
59
|
+
navigateTo,
|
|
49
60
|
nextSlide,
|
|
50
61
|
nextStep,
|
|
51
62
|
openUrlInNewTab,
|
|
@@ -65,6 +76,7 @@ import { useSwipeNav } from "../useSwipeNav.ts";
|
|
|
65
76
|
import { PresenterCastButton } from "./PresenterCastButton.tsx";
|
|
66
77
|
import { PresenterNotesPanel } from "./PresenterNotesPanel.tsx";
|
|
67
78
|
import { getPresenterNextPreview } from "./presenterPreview.ts";
|
|
79
|
+
import { formatPresenterElapsedTime } from "./presenterTime.ts";
|
|
68
80
|
|
|
69
81
|
// ---------------------------------------------------------------------------
|
|
70
82
|
// Helpers
|
|
@@ -163,14 +175,27 @@ function usePresenterMobile(): boolean {
|
|
|
163
175
|
// Component
|
|
164
176
|
// ---------------------------------------------------------------------------
|
|
165
177
|
|
|
166
|
-
|
|
178
|
+
type PresenterViewProps = {
|
|
179
|
+
colorMode: ColorMode;
|
|
180
|
+
onSetColorMode: (mode: ColorMode) => void;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export function PresenterView({
|
|
184
|
+
colorMode,
|
|
185
|
+
onSetColorMode,
|
|
186
|
+
}: PresenterViewProps) {
|
|
167
187
|
const route = useRoute();
|
|
168
188
|
const totalSlides = slideData.length;
|
|
169
189
|
const slide = Math.max(1, Math.min(route.slide, totalSlides || 1));
|
|
170
190
|
const step = Math.max(0, route.step);
|
|
171
191
|
|
|
172
192
|
const [clock, setClock] = useState(() => new Date().toLocaleTimeString());
|
|
193
|
+
const [timerState, setTimerState] = useState<"idle" | "running" | "paused">(
|
|
194
|
+
"idle",
|
|
195
|
+
);
|
|
196
|
+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
173
197
|
const [notes, setNotes] = useState<ReactNode>(null);
|
|
198
|
+
const [isBlankScreen, setIsBlankScreen] = useState(false);
|
|
174
199
|
const notesContextValue = useMemo(() => ({ setNotes }), []);
|
|
175
200
|
const isMobile = usePresenterMobile();
|
|
176
201
|
|
|
@@ -186,18 +211,27 @@ export function PresenterView() {
|
|
|
186
211
|
audienceUrl: getPresentationAudienceUrl({ view: "slide", slide, step }),
|
|
187
212
|
currentSlide: slide,
|
|
188
213
|
currentStep: step,
|
|
214
|
+
currentColorMode: colorMode,
|
|
189
215
|
});
|
|
216
|
+
const sendCastMessage = presentationCast.sendMessage;
|
|
217
|
+
const elapsedTime = formatPresenterElapsedTime(elapsedSeconds * 1000);
|
|
190
218
|
|
|
191
|
-
// ── Wall clock
|
|
219
|
+
// ── Wall clock + elapsed timer ─────────────────────────────────────────
|
|
192
220
|
useEffect(() => {
|
|
193
221
|
const timer = setInterval(() => {
|
|
194
222
|
setClock(new Date().toLocaleTimeString());
|
|
223
|
+
if (timerState === "running") setElapsedSeconds((value) => value + 1);
|
|
195
224
|
}, 1000);
|
|
196
225
|
return () => clearInterval(timer);
|
|
197
|
-
}, []);
|
|
226
|
+
}, [timerState]);
|
|
198
227
|
|
|
199
228
|
// ── BroadcastChannel: this window IS the presenter (controller) ─────────
|
|
200
|
-
|
|
229
|
+
const { broadcastBlankScreen } = useSync({
|
|
230
|
+
isPresenter: true,
|
|
231
|
+
currentSlide: slide,
|
|
232
|
+
currentStep: step,
|
|
233
|
+
currentColorMode: colorMode,
|
|
234
|
+
});
|
|
201
235
|
|
|
202
236
|
// ── Keyboard navigation ─────────────────────────────────────────────────
|
|
203
237
|
const getStepCount = useCallback(
|
|
@@ -211,6 +245,60 @@ export function PresenterView() {
|
|
|
211
245
|
});
|
|
212
246
|
useSwipeNav({ enabled: isMobile });
|
|
213
247
|
|
|
248
|
+
// ── Presenter exit + blank screen shortcuts ─────────────────────────────
|
|
249
|
+
const closePresenter = useCallback(() => {
|
|
250
|
+
navigateTo(getSlideRouteFromRoute({ view: "presenter", slide, step }));
|
|
251
|
+
}, [slide, step]);
|
|
252
|
+
|
|
253
|
+
const toggleBlankScreen = useCallback(() => {
|
|
254
|
+
const next = !isBlankScreen;
|
|
255
|
+
setIsBlankScreen(next);
|
|
256
|
+
const mode = next ? "black" : "off";
|
|
257
|
+
broadcastBlankScreen(mode);
|
|
258
|
+
sendCastMessage({ type: "blank-screen", mode });
|
|
259
|
+
}, [isBlankScreen, broadcastBlankScreen, sendCastMessage]);
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
function handlePresenterKeys(event: KeyboardEvent) {
|
|
263
|
+
if (event.key === "p" || event.key === "Escape") {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
closePresenter();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (event.key === "b" || event.key === "B") {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
toggleBlankScreen();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
window.addEventListener("keydown", handlePresenterKeys);
|
|
276
|
+
return () => window.removeEventListener("keydown", handlePresenterKeys);
|
|
277
|
+
}, [closePresenter, toggleBlankScreen]);
|
|
278
|
+
|
|
279
|
+
// ── Timer controls ──────────────────────────────────────────────────────
|
|
280
|
+
function startTimer() {
|
|
281
|
+
setTimerState("running");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function pauseTimer() {
|
|
285
|
+
setTimerState("paused");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function continueTimer() {
|
|
289
|
+
setTimerState("running");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function restartTimer() {
|
|
293
|
+
setElapsedSeconds(0);
|
|
294
|
+
setTimerState("running");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function closeTimer() {
|
|
298
|
+
setElapsedSeconds(0);
|
|
299
|
+
setTimerState("idle");
|
|
300
|
+
}
|
|
301
|
+
|
|
214
302
|
// ── Open audience window ────────────────────────────────────────────────
|
|
215
303
|
function openAudienceView() {
|
|
216
304
|
const url = getPresentationAudienceUrl({ view: "slide", slide, step });
|
|
@@ -246,6 +334,13 @@ export function PresenterView() {
|
|
|
246
334
|
|
|
247
335
|
return (
|
|
248
336
|
<div className="fixed inset-0 bg-black text-white font-sans grid grid-rows-[1fr_auto_auto] grid-cols-1 overflow-hidden select-none">
|
|
337
|
+
{/* ── Blank screen indicator overlay ────────────────────────────── */}
|
|
338
|
+
{isBlankScreen && (
|
|
339
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 px-4 py-2 rounded-md bg-red-600/90 text-white text-sm font-semibold tracking-wide uppercase">
|
|
340
|
+
Screen blanked (b)
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
|
|
249
344
|
{/* ── Top section: current slide plus desktop next/notes column ─── */}
|
|
250
345
|
<div
|
|
251
346
|
className={
|
|
@@ -284,33 +379,152 @@ export function PresenterView() {
|
|
|
284
379
|
/>
|
|
285
380
|
)}
|
|
286
381
|
|
|
287
|
-
{/* ── Bottom bar: counter ·
|
|
288
|
-
<div
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
382
|
+
{/* ── Bottom bar: counter · mobile nav buttons · timer/actions ──── */}
|
|
383
|
+
<div
|
|
384
|
+
className={
|
|
385
|
+
isMobile
|
|
386
|
+
? "grid grid-cols-[minmax(0,1fr)_auto] items-center px-3 py-2.5 border-t border-white/8 bg-black/30 gap-x-3 gap-y-2"
|
|
387
|
+
: "grid grid-cols-[minmax(0,1fr)_auto] items-center px-5 py-2.5 border-t border-white/8 bg-black/30 gap-x-4 gap-y-2"
|
|
388
|
+
}
|
|
389
|
+
>
|
|
390
|
+
{/* Counter + timer */}
|
|
391
|
+
<div className="min-w-0 flex flex-wrap items-center gap-3 text-md text-white/60 tabular-nums">
|
|
392
|
+
<span className="truncate">
|
|
393
|
+
Slide {slide}/{totalSlides}
|
|
394
|
+
{currentStepCount > 0 && ` · Step ${step}/${currentStepCount}`}
|
|
395
|
+
</span>
|
|
396
|
+
{timerState === "idle" && (
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={startTimer}
|
|
400
|
+
title="Start timer"
|
|
401
|
+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/6 text-white/50 hover:text-white/80 text-sm"
|
|
402
|
+
>
|
|
403
|
+
<PlayIcon aria-hidden="true" size={14} />
|
|
404
|
+
Start timer
|
|
405
|
+
</button>
|
|
406
|
+
)}
|
|
407
|
+
{timerState === "running" && (
|
|
408
|
+
<>
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={pauseTimer}
|
|
412
|
+
title="Pause timer"
|
|
413
|
+
className="text-lg font-semibold text-white tabular-nums bg-white/10 px-2.5 py-0.5 rounded"
|
|
414
|
+
>
|
|
415
|
+
⏱ {elapsedTime}
|
|
416
|
+
</button>
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
onClick={pauseTimer}
|
|
420
|
+
title="Pause timer"
|
|
421
|
+
aria-label="Pause timer"
|
|
422
|
+
className="text-white/50 hover:text-white/80 inline-flex"
|
|
423
|
+
>
|
|
424
|
+
<PauseIcon aria-hidden="true" size={16} />
|
|
425
|
+
</button>
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
{timerState === "paused" && (
|
|
429
|
+
<>
|
|
430
|
+
<span className="text-lg font-semibold text-white/60 tabular-nums bg-white/6 px-2.5 py-0.5 rounded">
|
|
431
|
+
⏱ {elapsedTime}
|
|
432
|
+
</span>
|
|
433
|
+
<button
|
|
434
|
+
type="button"
|
|
435
|
+
onClick={continueTimer}
|
|
436
|
+
title="Continue timer"
|
|
437
|
+
aria-label="Continue timer"
|
|
438
|
+
className="text-white/50 hover:text-white/80 inline-flex"
|
|
439
|
+
>
|
|
440
|
+
<PlayIcon aria-hidden="true" size={16} />
|
|
441
|
+
</button>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
onClick={restartTimer}
|
|
445
|
+
title="Restart timer from zero"
|
|
446
|
+
aria-label="Restart timer from zero"
|
|
447
|
+
className="text-white/30 hover:text-white/60 inline-flex"
|
|
448
|
+
>
|
|
449
|
+
<RotateCcwIcon aria-hidden="true" size={15} />
|
|
450
|
+
</button>
|
|
451
|
+
<button
|
|
452
|
+
type="button"
|
|
453
|
+
onClick={closeTimer}
|
|
454
|
+
title="Close timer"
|
|
455
|
+
aria-label="Close timer"
|
|
456
|
+
className="text-white/30 hover:text-white/60 inline-flex"
|
|
457
|
+
>
|
|
458
|
+
<XIcon aria-hidden="true" size={16} />
|
|
459
|
+
</button>
|
|
460
|
+
</>
|
|
461
|
+
)}
|
|
293
462
|
</div>
|
|
294
463
|
|
|
295
|
-
{/* Nav buttons */}
|
|
296
|
-
|
|
297
|
-
<
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
464
|
+
{/* Nav buttons are mobile-only; desktop uses keyboard shortcuts. */}
|
|
465
|
+
{isMobile && (
|
|
466
|
+
<div className="order-last col-span-2 flex w-full gap-2 items-center justify-center">
|
|
467
|
+
<NavButton
|
|
468
|
+
onClick={goPrev}
|
|
469
|
+
title="Previous step (←)"
|
|
470
|
+
className="h-12 w-16"
|
|
471
|
+
>
|
|
472
|
+
<ChevronLeftIcon aria-hidden="true" className="h-6 w-6" />
|
|
473
|
+
</NavButton>
|
|
474
|
+
<NavButton
|
|
475
|
+
onClick={goPrevSlide}
|
|
476
|
+
title="Previous slide (↑)"
|
|
477
|
+
className="h-12 w-16"
|
|
478
|
+
>
|
|
479
|
+
<ChevronUpIcon aria-hidden="true" className="h-6 w-6" />
|
|
480
|
+
</NavButton>
|
|
481
|
+
<NavButton
|
|
482
|
+
onClick={goNextSlide}
|
|
483
|
+
title="Next slide (↓)"
|
|
484
|
+
className="h-12 w-16"
|
|
485
|
+
>
|
|
486
|
+
<ChevronDownIcon aria-hidden="true" className="h-6 w-6" />
|
|
487
|
+
</NavButton>
|
|
488
|
+
<NavButton
|
|
489
|
+
onClick={goNext}
|
|
490
|
+
title="Next step (→)"
|
|
491
|
+
className="h-12 w-16"
|
|
492
|
+
>
|
|
493
|
+
<ChevronRightIcon aria-hidden="true" className="h-6 w-6" />
|
|
494
|
+
</NavButton>
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
310
497
|
|
|
311
|
-
{/* Clock + open
|
|
312
|
-
<div
|
|
313
|
-
|
|
498
|
+
{/* Clock + mode/open/cast buttons */}
|
|
499
|
+
<div
|
|
500
|
+
className={
|
|
501
|
+
isMobile
|
|
502
|
+
? "col-span-2 flex flex-wrap gap-3 items-center justify-self-start"
|
|
503
|
+
: "flex flex-wrap gap-3 items-center justify-self-end"
|
|
504
|
+
}
|
|
505
|
+
>
|
|
506
|
+
<span
|
|
507
|
+
className="text-md tabular-nums text-white/60"
|
|
508
|
+
title="Wall clock"
|
|
509
|
+
>
|
|
510
|
+
{clock}
|
|
511
|
+
</span>
|
|
512
|
+
<ColorModeCycleButton
|
|
513
|
+
colorMode={colorMode}
|
|
514
|
+
onSetColorMode={onSetColorMode}
|
|
515
|
+
iconSize={14}
|
|
516
|
+
className="w-8 h-8 rounded border border-white/20 bg-white/6 text-white/80 inline-flex items-center justify-center"
|
|
517
|
+
/>
|
|
518
|
+
<NavButton
|
|
519
|
+
onClick={toggleBlankScreen}
|
|
520
|
+
title={isBlankScreen ? "Unblank screen (b)" : "Blank screen (b)"}
|
|
521
|
+
>
|
|
522
|
+
<MonitorOffIcon
|
|
523
|
+
aria-hidden="true"
|
|
524
|
+
size={16}
|
|
525
|
+
className={isBlankScreen ? "text-red-400" : ""}
|
|
526
|
+
/>
|
|
527
|
+
</NavButton>
|
|
314
528
|
<button
|
|
315
529
|
type="button"
|
|
316
530
|
onClick={openAudienceView}
|
|
@@ -338,10 +552,12 @@ export function PresenterView() {
|
|
|
338
552
|
function NavButton({
|
|
339
553
|
onClick,
|
|
340
554
|
title,
|
|
555
|
+
className,
|
|
341
556
|
children,
|
|
342
557
|
}: {
|
|
343
558
|
onClick: () => void;
|
|
344
559
|
title?: string;
|
|
560
|
+
className?: string;
|
|
345
561
|
children: ReactNode;
|
|
346
562
|
}) {
|
|
347
563
|
return (
|
|
@@ -349,7 +565,8 @@ function NavButton({
|
|
|
349
565
|
type="button"
|
|
350
566
|
onClick={onClick}
|
|
351
567
|
title={title}
|
|
352
|
-
|
|
568
|
+
aria-label={title}
|
|
569
|
+
className={`w-9 h-9 rounded border border-white/15 bg-white/6 text-white/75 text-md flex items-center justify-center font-[inherit] ${className ?? ""}`}
|
|
353
570
|
>
|
|
354
571
|
{children}
|
|
355
572
|
</button>
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
- Navigation controls button
|
|
11
11
|
- Direct URL: `/#/presenter/1/0`
|
|
12
12
|
|
|
13
|
+
### Deactivation
|
|
14
|
+
|
|
15
|
+
- Keyboard shortcut `p` exits presenter mode back to audience slide view at the same slide/step.
|
|
16
|
+
- Keyboard shortcut `Escape` exits presenter mode back to audience slide view at the same slide/step.
|
|
17
|
+
|
|
13
18
|
### UI Layout
|
|
14
19
|
|
|
15
20
|
```txt
|
|
@@ -25,7 +30,7 @@
|
|
|
25
30
|
│ - Remember to demo the sparkle button │
|
|
26
31
|
│ - Mention PDF export │
|
|
27
32
|
│ │
|
|
28
|
-
│ Slide 3/12 · Step 2/4
|
|
33
|
+
│ Slide 3/12 · Step 2/4 12:34 Timer 1:23 [Open] │
|
|
29
34
|
└──────────────────────────────────────────┘
|
|
30
35
|
```
|
|
31
36
|
|
|
@@ -35,9 +40,12 @@ Includes:
|
|
|
35
40
|
- Speaker notes for current slide, with Markdown formatting from `<Notes>` rendered as compact presenter prose
|
|
36
41
|
- Slide/step counter
|
|
37
42
|
- Clock (wall clock)
|
|
43
|
+
- Elapsed presentation timer sits next to the slide/step counter on the left side of the bottom bar and has idle/running/paused states. Idle shows a start action. Running shows a prominent elapsed display plus pause action. Paused shows elapsed time plus continue, restart-from-zero, and close/reset actions.
|
|
38
44
|
- Button to open audience view in new tab, preserving the current slide/step and deck base path
|
|
39
|
-
-
|
|
40
|
-
-
|
|
45
|
+
- Color mode cycle button (system → light → dark → system). Presenter color mode changes also sync to BroadcastChannel audience views and Presentation API cast receivers.
|
|
46
|
+
- Blank screen toggle button and `b` keyboard shortcut. While blanked, the presenter sees a `Screen blanked (b)` indicator and audience/cast views see a black screen.
|
|
47
|
+
- Button to cast the audience view to a secondary display when the Presentation API is supported. Unsupported browsers keep a visibly disabled-looking control in the action row; it does not render extra inline feedback, and its hover title/accessible label explains that Presentation API casting is unavailable. Active casting can be stopped from the same control.
|
|
48
|
+
- Presenter navigation buttons provide previous/next timeline-step navigation and previous/next slide navigation on mobile presenter layouts. Desktop presenter layouts do not show navigation buttons. On mobile, the timeline-step buttons sit on the outside edges of the button group (previous step, previous slide, next slide, next step), because step navigation is the primary/default action. Timeline keyboard shortcuts (`→`/`←`/`↓`/`↑`, `d`/`a`/`s`/`w`) update the presenter route and keep the window in presenter mode.
|
|
41
49
|
- Presenter navigation uses the shared Honeydeck navigation command abstraction so button, keyboard, and touch inputs share the same semantics as audience view.
|
|
42
50
|
- Presenter notes are scroll-owned regions: wheel, trackpad, touch scroll, and swipe gestures that start in notes scroll notes and never navigate slides, even at scroll boundaries.
|
|
43
51
|
- On mobile presenter layouts, the Current preview may use tap zones and swipe navigation; speaker notes remain scroll-only. Pinch-to-zoom and pinch-to-overview are not required in presenter mode.
|
|
@@ -47,7 +55,22 @@ Includes:
|
|
|
47
55
|
|
|
48
56
|
### Presenter Responsiveness
|
|
49
57
|
|
|
50
|
-
Presenter mode uses a two-column preview area (`Current` larger, `Next` smaller), a notes panel, and a bottom status/action bar on desktop. On narrow/mobile screens it switches to a single-column layout and hides the Next preview.
|
|
58
|
+
Presenter mode uses a two-column preview area (`Current` larger, `Next` smaller), a notes panel, and a bottom status/action bar on desktop. On narrow/mobile screens it switches to a single-column layout and hides the Next preview. The navigation button group is the bottom-most element on mobile and uses larger touch targets than desktop.
|
|
59
|
+
|
|
60
|
+
### Presentation Timer
|
|
61
|
+
|
|
62
|
+
- Idle state shows a `Start timer` button.
|
|
63
|
+
- Starting changes to running state, displaying elapsed time as `MM:SS` or `H:MM:SS`.
|
|
64
|
+
- In running state, clicking the elapsed time or pause action pauses the timer.
|
|
65
|
+
- In paused state, controls let the presenter continue, restart from zero, or close/reset the timer back to idle.
|
|
66
|
+
|
|
67
|
+
### Blank Screen
|
|
68
|
+
|
|
69
|
+
- Pressing `b` toggles the audience screen to black; pressing `b` again restores the normal view.
|
|
70
|
+
- A blank screen button in the bottom action bar provides the same toggle.
|
|
71
|
+
- While blanked, the presenter sees a `Screen blanked (b)` indicator overlay.
|
|
72
|
+
- The blank-screen state is broadcast via both `BroadcastChannel` and the Presentation API cast connection as a `blank-screen` sync message with `mode: "black" | "off"`.
|
|
73
|
+
- When the presenter disconnects, audience windows automatically unblank.
|
|
51
74
|
|
|
52
75
|
### Audience Sync
|
|
53
76
|
|
|
@@ -55,7 +78,7 @@ Presenter mode and audience view synchronize via `BroadcastChannel` and the Pres
|
|
|
55
78
|
|
|
56
79
|
- Same browser/profile BroadcastChannel sync remains available when casting is unsupported or unavailable
|
|
57
80
|
- Presenter mode is the controller
|
|
58
|
-
- Audience view listens for navigation updates
|
|
81
|
+
- Audience view listens for navigation updates, presenter color mode changes, and blank-screen commands
|
|
59
82
|
- Late-opening audience tabs request the current presenter position via a `sync-request` / `sync-response` handshake as soon as a receiver connection is available, so they sync immediately instead of waiting for the next presenter move
|
|
60
83
|
- Presence messages (`presenter-connected` / `presenter-disconnected`) are broadcast
|
|
61
84
|
- When the Presentation API is supported, presenter mode can cast the audience view to a secondary display; the receiver asks for the current route when a connection becomes available and presenter replies with a `sync-response` so the cast audience resyncs even if it missed the first `navigate` message
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function formatPresenterElapsedTime(elapsedMs: number): string {
|
|
2
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
3
|
+
const seconds = totalSeconds % 60;
|
|
4
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
5
|
+
const minutes = totalMinutes % 60;
|
|
6
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
7
|
+
|
|
8
|
+
if (hours > 0) {
|
|
9
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
|
|
10
|
+
.toString()
|
|
11
|
+
.padStart(2, "0")}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
15
|
+
}
|