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

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