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 ? ( + <> + + {mode === 'outline' && ( + + + + + )} + + ) : ( + <> + + {mode === 'outline' && } + + )} +
+ ) : null} {mode === 'script' ? ( + <> + {banner}
{script.trim() ?
{script} :

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 +
+
+

The audience sees

+
- ) :

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.

} +
+ )}
{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); +});