Present mode: private speaker notes, audience window, presenter view

- Speaker notes are hidden on the presenting screen by default (N / "Show notes"
  turns them on for one-screen use), so a mirrored display never shows them.
- "Audience window" opens a second window for a projector (?audience=1, no sign-in)
  that shows only the point, its passage and quoted verse. Notes, minutes, the
  script and the timer are never sent to it.
- With it connected this screen becomes a presenter view: what the audience sees,
  your notes, the next point, plus Blank audience (B) and audience text size.
- Script mode sends the audience only the title.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 13:23:16 -04:00
1 parent 0f1a181d87
commit 7ba5ed41b5
7 files changed
+317 -15

No files matched your search

+5
View File
@@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js'; import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js'; import { useBibleReader } from './hooks/useBibleReader.js';
import { isAudienceWindow } from './utils/presentSync.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js'; import { useHistoryNav } from './hooks/useHistoryNav.js';
import { usePrayers } from './hooks/usePrayers.js'; import { usePrayers } from './hooks/usePrayers.js';
@@ -37,6 +38,7 @@ const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx')); const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx')); const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
const SetupPage = lazy(() => import('./pages/SetupPage.jsx')); const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
const AudienceView = lazy(() => import('./components/AudienceView.jsx'));
const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx')); const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx'));
const StudyPage = lazy(() => import('./pages/StudyPage.jsx')); const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx')); const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
@@ -586,6 +588,9 @@ function AppRouter() {
// A read-only outline link: no sign-in, nothing editable // A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />; if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
// The second window for presenting on a projector: no sign-in, it only shows what the presenter window sends.
if (isAudienceWindow()) return <Suspense fallback={null}><AudienceView /></Suspense>;
if (sharedViewToken) { if (sharedViewToken) {
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />; if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
if (sharedError) { if (sharedError) {
+56
View File
@@ -0,0 +1,56 @@
// The audience window for two-screen presenting: drag it to the projector or second display and press F (or click)
// for full screen. It only draws what the presenter window sends, which never includes private notes.
import { useEffect, useState } from 'react';
import StepFace from './StepFace.jsx';
import { openChannel } from '../utils/presentSync.js';
export default function AudienceView() {
const [state, setState] = useState(null);
const [ended, setEnded] = useState(false);
const [hint, setHint] = useState(true);
useEffect(() => {
document.title = 'Audience display';
const channel = openChannel();
if (!channel) return undefined;
channel.onmessage = (e) => {
if (e.data?.type === 'state') { setState(e.data); setEnded(false); }
else if (e.data?.type === 'end') setEnded(true);
};
channel.postMessage({ type: 'hello' }); // ask a presenter already running to send what it is showing
return () => channel.close();
}, []);
useEffect(() => {
const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); };
const onKey = (e) => { if (e.key === 'f' || e.key === 'F') fullscreen(); };
window.addEventListener('keydown', onKey);
const timer = window.setTimeout(() => setHint(false), 8000);
return () => { window.removeEventListener('keydown', onKey); window.clearTimeout(timer); };
}, []);
const dark = state?.dark ?? true;
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
const fontSize = `${state?.fontPx ?? 40}px`;
let body;
if (!state || ended) {
body = <p className="text-center text-lg opacity-60">{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}</p>;
} else if (state.blank) {
body = null;
} else if (state.mode === 'outline' && state.step) {
body = <StepFace step={state.step} index={state.index} total={state.total} fontSize={fontSize} />;
} else {
body = (
<div className="text-center font-serif" style={{ fontSize }}>
<h1 className="font-semibold">{state.title}</h1>
{state.subtitle && <p className="mt-3 text-[0.55em] opacity-70">{state.subtitle}</p>}
</div>
);
}
return (
<div className={`fixed inset-0 flex cursor-none items-center justify-center overflow-hidden px-[6vw] py-[6dvh] ${state?.blank ? 'bg-black' : colors}`} onClick={() => document.documentElement.requestFullscreen?.().catch(() => {})}>
{body}
{hint && !state?.blank && <p className="fixed bottom-3 left-0 right-0 text-center font-sans text-xs opacity-40">Press F or click for full screen</p>}
</div>
);
}
+103 -12
View File
@@ -1,8 +1,15 @@
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by // Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you // itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves. // planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
//
// With a projector or second screen: "Audience window" opens a second window to drag over. It shows only the point,
// its passage and quoted verse; your speaker notes, the script, the timer and the plan stay on this screen. This
// screen then becomes a presenter view (what the audience sees, your notes, the next point). On one screen, notes are
// hidden unless you turn them on, so a mirrored display never shows them by accident. B blacks out the audience screen.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js'; import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
import { audienceState, audienceUrl, openChannel } from '../utils/presentSync.js';
import StepFace from './StepFace.jsx';
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72]; const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40'; const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
@@ -16,10 +23,43 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
const [speed, setSpeed] = useState(30); // pixels per second const [speed, setSpeed] = useState(30); // pixels per second
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const scroller = useRef(null); const scroller = useRef(null);
const [showNotes, setShowNotes] = useState(() => { try { return localStorage.getItem('present-notes-here') === 'on'; } catch { return false; } });
const [audienceOpen, setAudienceOpen] = useState(false); // an audience window is connected: this screen is the presenter's
const [blank, setBlank] = useState(false); // the audience screen is blacked out
const [audSize, setAudSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-aud-size')) || 48)); } catch { return 5; } });
const [popupBlocked, setPopupBlocked] = useState(false);
const audienceWin = useRef(null);
const channel = useRef(null);
const latest = useRef(null);
const steps = mode === 'outline' ? outlineSteps(tree) : []; const steps = mode === 'outline' ? outlineSteps(tree) : [];
const step = steps[index]; const step = steps[index];
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null; const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
// Two screens: keep the audience window in step with this one, answering a window that has just opened.
const audience = audienceState({ title, subtitle, mode, step, index, total: steps.length, dark, fontPx: SIZES[audSize], blank });
latest.current = audience;
useEffect(() => {
const ch = openChannel();
channel.current = ch;
if (!ch) return undefined;
ch.onmessage = (e) => { if (e.data?.type === 'hello') { setAudienceOpen(true); ch.postMessage(latest.current); } };
return () => { ch.postMessage({ type: 'end' }); ch.close(); channel.current = null; };
}, []);
useEffect(() => { channel.current?.postMessage(audience); }); // every change: the point, the colours, blank
useEffect(() => {
if (!audienceOpen) return undefined;
const t = window.setInterval(() => { if (audienceWin.current?.closed) { audienceWin.current = null; setAudienceOpen(false); setBlank(false); } }, 1000);
return () => window.clearInterval(t);
}, [audienceOpen]);
const openAudience = () => {
if (!channel.current) return;
const win = window.open(audienceUrl(), 'bible-study-audience', 'popup,width=1280,height=720');
setPopupBlocked(!win);
if (win) { audienceWin.current = win; setAudienceOpen(true); }
};
const toggleNotes = () => setShowNotes((on) => { const next = !on; try { localStorage.setItem('present-notes-here', next ? 'on' : 'off'); } catch { /* storage unavailable */ } return next; });
const audBigger = (d) => setAudSize((i) => { const next = Math.min(SIZES.length - 1, Math.max(0, i + d)); try { localStorage.setItem('present-aud-size', String(SIZES[next])); } catch { /* storage unavailable */ } return next; });
// the clock // the clock
useEffect(() => { useEffect(() => {
if (!running) return undefined; if (!running) return undefined;
@@ -68,13 +108,20 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); } if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); } } else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
if ((e.key === 'b' || e.key === 'B') && audienceOpen) setBlank((b) => !b);
if ((e.key === 'n' || e.key === 'N') && !audienceOpen && mode === 'outline') toggleNotes();
if (e.key === '+' || e.key === '=') bigger(1); if (e.key === '+' || e.key === '=') bigger(1);
if (e.key === '-') bigger(-1); if (e.key === '-') bigger(-1);
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [mode, go, onClose]); }, [mode, go, onClose, audienceOpen]);
const banner = audienceOpen && (
<p role="status" className={`shrink-0 px-4 py-1.5 text-center text-xs ${blank ? 'bg-red-500/25' : 'bg-emerald-500/15'}`}>
{blank ? 'The audience screen is blank. Press B to bring it back.' : mode === 'script' ? 'The audience window is connected. It shows only the title; your script stays on this screen.' : 'The audience window is connected. It shows the points and verses, never your notes.'}
</p>
);
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]'; const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
const fontSize = `${SIZES[size]}px`; const fontSize = `${SIZES[size]}px`;
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0; const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
@@ -102,29 +149,73 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
</label> </label>
</div> </div>
)} )}
{typeof BroadcastChannel !== 'undefined' ? (
<div className="flex items-center gap-2" role="group" aria-label="Second screen">
{audienceOpen ? (
<>
<button type="button" onClick={() => setBlank((b) => !b)} aria-pressed={blank} className={btn} title="Black out the audience screen (B)">{blank ? 'Show audience' : 'Blank audience'}</button>
{mode === 'outline' && (
<span className="flex items-center gap-1" role="group" aria-label="Audience text size">
<button type="button" onClick={() => audBigger(-1)} disabled={audSize === 0} aria-label="Smaller audience text" className={btn}>Screen A−</button>
<button type="button" onClick={() => audBigger(1)} disabled={audSize === SIZES.length - 1} aria-label="Larger audience text" className={btn}>A+</button>
</span>
)}
</>
) : (
<>
<button type="button" onClick={openAudience} className={btn} title="Opens a second window for a projector or other screen. It shows the points, never your notes.">Audience window</button>
{mode === 'outline' && <button type="button" onClick={toggleNotes} aria-pressed={showNotes} className={btn} title="Speaker notes stay hidden on this screen unless you turn them on (N)">{showNotes ? 'Hide notes' : 'Show notes'}</button>}
</>
)}
</div>
) : null}
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button> <button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
</div> </div>
{mode === 'script' ? ( {mode === 'script' ? (
<>
{banner}
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}> <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
{script.trim() {script.trim()
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div> ? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>} : <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
</div> </div>
</>
) : ( ) : (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]"> {popupBlocked && <p role="alert" className="shrink-0 bg-amber-500/20 px-4 py-1.5 text-center text-xs">Your browser blocked the audience window. Allow pop-ups for this site, then try again.</p>}
{step ? ( {banner}
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}> {audienceOpen && step ? (
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p> // Presenter view: what the audience sees, your private notes, and what comes next
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1> <div className="grid min-h-0 flex-1 gap-0 md:grid-cols-[3fr_2fr]">
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>} <div className="min-h-0 overflow-y-auto border-current/15 px-[3vw] py-[4dvh] md:border-r">
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>} <p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">The audience sees</p>
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>} <StepFace step={step} index={index} total={steps.length} fontSize={`${SIZES[Math.max(0, size - 3)]}px`} minutes={step.minutes} />
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
</div> </div>
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>} <div className="min-h-0 overflow-y-auto px-[3vw] py-[4dvh]">
</div> <p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Only you see this</p>
{step.note.trim()
? <p className="whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.45 }}>{step.note}</p>
: <p className="font-sans text-sm opacity-50">No speaker notes for this point. Add them under the point’s “⋯” details on the Outline page.</p>}
{steps[index + 1] && (
<div className="mt-8 border-t border-current/15 pt-4">
<p className="font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">Next</p>
<p className="mt-1 font-serif text-xl"><span className="mr-2 opacity-60">{steps[index + 1].label}</span>{steps[index + 1].text || '(untitled)'}</p>
{steps[index + 1].minutes > 0 && <p className="font-sans text-xs opacity-60">{steps[index + 1].minutes} min</p>}
</div>
)}
</div>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
{step ? (
<>
<StepFace step={step} index={index} total={steps.length} fontSize={fontSize} minutes={step.minutes} note={showNotes ? step.note : ''} />
{showNotes && step.note && <p className="mx-auto mt-2 max-w-[46em] font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Speaker notes are showing on this screen (N to hide)</p>}
</>
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
</div>
)}
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3"> <div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button> <button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span> <span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
+78 -3
View File
@@ -1,9 +1,10 @@
import { render, screen, act } from '@testing-library/react'; import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest'; import { vi, test, expect, afterEach, beforeEach } from 'vitest';
import PresentMode from './PresentMode.jsx'; import PresentMode from './PresentMode.jsx';
afterEach(() => vi.useRealTimers()); beforeEach(() => localStorage.clear());
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra }); const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })]; const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
@@ -11,7 +12,7 @@ test('the outline is stepped through a point at a time, with the keyboard or the
const user = userEvent.setup(); const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />); render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
expect(screen.getByText('Welcome')).toBeInTheDocument(); expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.getByText('Say hello')).toBeInTheDocument(); expect(screen.queryByText('Say hello')).not.toBeInTheDocument(); // speaker notes are private by default
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument(); expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
await user.keyboard('{ArrowRight}'); await user.keyboard('{ArrowRight}');
@@ -58,3 +59,77 @@ test('Escape and Close both leave, and an empty script says what to do', async (
await user.click(screen.getByRole('button', { name: 'Close' })); await user.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2); expect(onClose).toHaveBeenCalledTimes(2);
}); });
// A stand-in for the browser's BroadcastChannel that records what the presenter window sends
class FakeChannel {
static instances = [];
constructor() { this.sent = []; FakeChannel.instances.push(this); }
postMessage(m) { this.sent.push(m); }
close() {}
}
const withChannel = () => { FakeChannel.instances = []; vi.stubGlobal('BroadcastChannel', FakeChannel); };
const last = () => FakeChannel.instances[0].sent.filter((m) => m.type === 'state').at(-1);
test('on one screen the speaker notes stay hidden until you turn them on', async () => {
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Show notes' }));
expect(screen.getByText('Say hello')).toBeInTheDocument();
expect(localStorage.getItem('present-notes-here')).toBe('on');
await user.click(screen.getByRole('button', { name: 'Hide notes' }));
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
});
test('with an audience window the notes appear only on the presenter screen, never in what is sent', async () => {
withChannel();
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
await user.click(screen.getByRole('button', { name: 'Audience window' }));
expect(window.open).toHaveBeenCalledWith(expect.stringContaining('?audience=1'), 'bible-study-audience', expect.any(String));
expect(screen.getByText('Only you see this')).toBeInTheDocument();
expect(screen.getByText('Say hello')).toBeInTheDocument(); // the presenter's own screen
expect(screen.getByText(/never your notes/i)).toBeInTheDocument();
expect(last()).toMatchObject({ mode: 'outline', index: 0, total: 2, blank: false });
expect(last().step.text).toBe('Welcome');
const everything = JSON.stringify(FakeChannel.instances[0].sent);
expect(everything).not.toContain('Say hello');
expect(screen.getByText('Next')).toBeInTheDocument(); // the preview of the next point
await user.keyboard('{ArrowRight}');
expect(last().step.text).toBe('The gospel');
});
test('a window that opens later is brought up to date, and B blacks the audience screen out', async () => {
withChannel();
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const channel = FakeChannel.instances[0];
act(() => channel.onmessage({ data: { type: 'hello' } })); // someone opened the audience address by hand
expect(await screen.findByText('Only you see this')).toBeInTheDocument();
expect(channel.sent.at(-1).step.text).toBe('Welcome');
await user.keyboard('b');
expect(last().blank).toBe(true);
expect(screen.getByText(/audience screen is blank/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Show audience' }));
expect(last().blank).toBe(false);
});
test('in script mode the audience is only ever sent the title', async () => {
withChannel();
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
const user = userEvent.setup();
render(<PresentMode mode="script" title="Romans 8" script="My private script text" onClose={() => {}} />);
await user.click(screen.getByRole('button', { name: 'Audience window' }));
expect(last()).toMatchObject({ mode: 'script', title: 'Romans 8', step: null });
expect(JSON.stringify(FakeChannel.instances[0].sent)).not.toContain('private script');
expect(screen.getByText(/only the title/i)).toBeInTheDocument();
});
test('the presentation ending tells the audience window', () => {
withChannel();
const { unmount } = render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const channel = FakeChannel.instances[0];
unmount();
expect(channel.sent.at(-1)).toEqual({ type: 'end' });
});
+15
View File
@@ -0,0 +1,15 @@
// One outline point as it appears on screen: the point, its passage and quoted verse, and what it carries.
// Used for the whole screen in present mode, for the presenter's preview, and on the audience window. `note` and
// `minutes` are only passed where the speaker is the one looking.
export default function StepFace({ step, index, total, fontSize, minutes = null, note = '' }) {
return (
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {total}{minutes ? ` · ${minutes} min` : ''}</p>
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
{note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{note}</p>}
{step.attachments?.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Present mode on two screens: the presenter window sends what the audience may see to a second "audience"
// window (the one dragged to the projector) over a BroadcastChannel. Private things (a point's speaker notes, the
// script, planned minutes, the timer) are never put in the message, so the audience window cannot show them.
export const CHANNEL = 'bible-study-present';
export const AUDIENCE_PARAM = 'audience';
export const audienceUrl = (loc = window.location) => `${loc.origin}${loc.pathname}?${AUDIENCE_PARAM}=1`;
export const isAudienceWindow = (search = typeof window === 'undefined' ? '' : window.location.search) => new URLSearchParams(search).has(AUDIENCE_PARAM);
// null where the browser has no BroadcastChannel (then there is no audience window, only the one screen)
export function openChannel() {
try { return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL); } catch { return null; }
}
// The parts of an outline point that belong on the projector.
export function publicStep(step) {
if (!step) return null;
return {
label: step.label ?? '', kind: step.kind ?? '', text: step.text ?? '', ref: step.ref ?? '',
scripture: step.scripture?.text ? { text: step.scripture.text, ref: step.scripture.ref ?? '' } : null,
attachments: (step.attachments ?? []).map((a) => ({ type: a.type, label: a.label })),
};
}
// The message the audience window draws from. In script mode the audience gets only the title (the script is for the
// speaker); `blank` blacks the projector out.
export function audienceState({ title, subtitle = '', mode, step, index = 0, total = 0, dark = true, fontPx = 40, blank = false }) {
return {
type: 'state', title, subtitle, mode, dark, fontPx, blank: !!blank, index, total,
step: mode === 'outline' ? publicStep(step) : null,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { test, expect } from 'vitest';
import { audienceState, audienceUrl, isAudienceWindow, publicStep } from './presentSync.js';
const step = { id: 'a', label: 'I.', depth: 0, text: 'Welcome', ref: 'Rom 1:16', note: 'SECRET speaker note', minutes: 7, plannedEnd: 7, kind: 'quote',
scripture: { text: 'For I am not ashamed', ref: 'Romans 1:16', translation: 'BSB' }, attachments: [{ id: 'x', type: 'word', label: 'pistis', detail: 'PRIVATE detail' }] };
test('what the audience gets of a point never includes the private note, minutes or details', () => {
const pub = publicStep(step);
expect(pub).toEqual({ label: 'I.', kind: 'quote', text: 'Welcome', ref: 'Rom 1:16', scripture: { text: 'For I am not ashamed', ref: 'Romans 1:16' }, attachments: [{ type: 'word', label: 'pistis' }] });
const message = JSON.stringify(audienceState({ title: 'Romans', mode: 'outline', step, index: 0, total: 3 }));
expect(message).not.toMatch(/SECRET|PRIVATE|minutes|plannedEnd/);
expect(message).toContain('Welcome');
});
test('in script mode the audience gets the title only, and blank blacks the screen out', () => {
const state = audienceState({ title: 'Romans 8', subtitle: 'Session 1', mode: 'script', step, blank: true });
expect(state.step).toBeNull();
expect(state.blank).toBe(true);
expect(JSON.stringify(state)).not.toMatch(/SECRET|Welcome/);
expect(publicStep(null)).toBeNull();
});
test('the audience window is recognised by its address', () => {
expect(audienceUrl({ origin: 'https://study.test', pathname: '/' })).toBe('https://study.test/?audience=1');
expect(isAudienceWindow('?audience=1')).toBe(true);
expect(isAudienceWindow('?share=abc')).toBe(false);
expect(isAudienceWindow('')).toBe(false);
});