diff --git a/src/App.jsx b/src/App.jsx
index 2159553..10ffb59 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
+import { isAudienceWindow } from './utils/presentSync.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.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 SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
+const AudienceView = lazy(() => import('./components/AudienceView.jsx'));
const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx'));
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
@@ -586,6 +588,9 @@ function AppRouter() {
// A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return ;
+ // The second window for presenting on a projector: no sign-in, it only shows what the presenter window sends.
+ if (isAudienceWindow()) return ;
+
if (sharedViewToken) {
if (needsPasscode) return ;
if (sharedError) {
diff --git a/src/components/AudienceView.jsx b/src/components/AudienceView.jsx
new file mode 100644
index 0000000..2f52dc4
--- /dev/null
+++ b/src/components/AudienceView.jsx
@@ -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 =
{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}
;
+ } else if (state.blank) {
+ body = null;
+ } else if (state.mode === 'outline' && state.step) {
+ body = ;
+ } else {
+ body = (
+
+
{state.title}
+ {state.subtitle &&
{state.subtitle}
}
+
+ );
+ }
+ return (
+ document.documentElement.requestFullscreen?.().catch(() => {})}>
+ {body}
+ {hint && !state?.blank &&
Press F or click for full screen
}
+
+ );
+}
diff --git a/src/components/PresentMode.jsx b/src/components/PresentMode.jsx
index 1e2008e..f40ae32 100644
--- a/src/components/PresentMode.jsx
+++ b/src/components/PresentMode.jsx
@@ -1,8 +1,15 @@
// 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
// 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 { 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 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 [index, setIndex] = useState(0);
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 step = steps[index];
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
useEffect(() => {
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); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
} 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 === '-') bigger(-1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
- }, [mode, go, onClose]);
+ }, [mode, go, onClose, audienceOpen]);
+ const banner = audienceOpen && (
+
+ {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.'}
+
+ );
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
const fontSize = `${SIZES[size]}px`;
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
@@ -102,29 +149,73 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
)}
+ {typeof BroadcastChannel !== 'undefined' ? (
+
+ {audienceOpen ? (
+ <>
+ setBlank((b) => !b)} aria-pressed={blank} className={btn} title="Black out the audience screen (B)">{blank ? 'Show audience' : 'Blank audience'}
+ {mode === 'outline' && (
+
+ audBigger(-1)} disabled={audSize === 0} aria-label="Smaller audience text" className={btn}>Screen A−
+ audBigger(1)} disabled={audSize === SIZES.length - 1} aria-label="Larger audience text" className={btn}>A+
+
+ )}
+ >
+ ) : (
+ <>
+ Audience window
+ {mode === 'outline' && {showNotes ? 'Hide notes' : 'Show notes'} }
+ >
+ )}
+
+ ) : null}
Close
{mode === 'script' ? (
+ <>
+ {banner}
{script.trim()
?
:
There’s no script to present yet. Write one on the Script page.
}
+ >
) : (
-
- {step ? (
-
-
{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}
-
{step.label} {step.text || '(untitled)'}
- {step.ref &&
{step.ref}
}
- {step.scripture?.text &&
“{step.scripture.text}” — {step.scripture.ref} }
- {step.note &&
{step.note}
}
- {step.attachments.length > 0 &&
{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}
}
+ {popupBlocked &&
Your browser blocked the audience window. Allow pop-ups for this site, then try again.
}
+ {banner}
+ {audienceOpen && step ? (
+ // Presenter view: what the audience sees, your private notes, and what comes next
+
+
- ) :
There’s no outline to present yet. Build one on the Outline page.
}
-
+
+
Only you see this
+ {step.note.trim()
+ ?
{step.note}
+ :
No speaker notes for this point. Add them under the point’s “⋯” details on the Outline page.
}
+ {steps[index + 1] && (
+
+
Next
+
{steps[index + 1].label} {steps[index + 1].text || '(untitled)'}
+ {steps[index + 1].minutes > 0 &&
{steps[index + 1].minutes} min
}
+
+ )}
+
+
+ ) : (
+
+ {step ? (
+ <>
+
+ {showNotes && step.note &&
Speaker notes are showing on this screen (N to hide)
}
+ >
+ ) :
There’s no outline to present yet. Build one on the Outline page.
}
+
+ )}
go(-1)} disabled={index === 0} className={btn}>← Back
{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move
diff --git a/src/components/PresentMode.test.jsx b/src/components/PresentMode.test.jsx
index e7b8232..dc128fa 100644
--- a/src/components/PresentMode.test.jsx
+++ b/src/components/PresentMode.test.jsx
@@ -1,9 +1,10 @@
import { render, screen, act } from '@testing-library/react';
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';
-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 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();
render(
{}} />);
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.getByRole('button', { name: /back/i })).toBeDisabled();
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' }));
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( {}} />);
+ 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( {}} />);
+ 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( {}} />);
+ 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( {}} />);
+ 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( {}} />);
+ const channel = FakeChannel.instances[0];
+ unmount();
+ expect(channel.sent.at(-1)).toEqual({ type: 'end' });
+});
diff --git a/src/components/StepFace.jsx b/src/components/StepFace.jsx
new file mode 100644
index 0000000..f68e434
--- /dev/null
+++ b/src/components/StepFace.jsx
@@ -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 (
+
+
{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {total}{minutes ? ` · ${minutes} min` : ''}
+
{step.label} {step.text || '(untitled)'}
+ {step.ref &&
{step.ref}
}
+ {step.scripture?.text &&
“{step.scripture.text}” — {step.scripture.ref} }
+ {note &&
{note}
}
+ {step.attachments?.length > 0 &&
{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}
}
+
+ );
+}
diff --git a/src/utils/presentSync.js b/src/utils/presentSync.js
new file mode 100644
index 0000000..01f31bc
--- /dev/null
+++ b/src/utils/presentSync.js
@@ -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,
+ };
+}
diff --git a/src/utils/presentSync.test.js b/src/utils/presentSync.test.js
new file mode 100644
index 0000000..72da957
--- /dev/null
+++ b/src/utils/presentSync.test.js
@@ -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);
+});