// 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'; export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) { const [dark, setDark] = useState(true); const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } }); const [elapsed, setElapsed] = useState(0); const [running, setRunning] = useState(false); const [scrolling, setScrolling] = useState(false); 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; const t = window.setInterval(() => setElapsed((e) => e + 1), 1000); return () => window.clearInterval(t); }, [running]); // the teleprompter useEffect(() => { if (!scrolling || mode !== 'script') return undefined; let last = performance.now(); let frame; let carry = 0; const tick = (now) => { const el = scroller.current; carry += ((now - last) / 1000) * speed; last = now; if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); } if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; } frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [scrolling, speed, mode]); // keep the screen on useEffect(() => { let lock; let gone = false; navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {}); return () => { gone = true; lock?.release?.().catch?.(() => {}); }; }, []); const bigger = (d) => setSize((s) => { const next = Math.min(SIZES.length - 1, Math.max(0, s + d)); try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ } return next; }); const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') { onClose(); return; } if (e.target?.tagName === 'INPUT') return; if (mode === 'outline') { 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, 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; return ({title}
{subtitle &&{subtitle}
}There’s no script to present yet. Write one on the Script page.
}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 nextThe audience sees
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
}Speaker notes are showing on this screen (N to hide)
} > ) :There’s no outline to present yet. Build one on the Outline page.
}