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

+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
// 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 && (
<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 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
</label>
</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>
</div>
{mode === 'script' ? (
<>
{banner}
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
{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>
: <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 className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
{step ? (
<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 {steps.length}{step.minutes ? ` · ${step.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>}
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.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>}
{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>}
{banner}
{audienceOpen && step ? (
// Presenter view: what the audience sees, your private notes, and what comes next
<div className="grid min-h-0 flex-1 gap-0 md:grid-cols-[3fr_2fr]">
<div className="min-h-0 overflow-y-auto border-current/15 px-[3vw] py-[4dvh] md:border-r">
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">The audience sees</p>
<StepFace step={step} index={index} total={steps.length} fontSize={`${SIZES[Math.max(0, size - 3)]}px`} minutes={step.minutes} />
</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>
<div className="min-h-0 overflow-y-auto px-[3vw] py-[4dvh]">
<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">
<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>