// Hymnal tab — projection view (mirrors the Slides layout): a black projection
// stage on the left showing the extracted hymn lyrics verse-by-verse, and a
// searchable list of the hymn database on the right. Read-only database
// (window.HYMNAL_DB, extracted from the PowerPoint hymn files).
function HymnalTab({ active, canPlan=false, edits={}, setEdits }){
const db = window.HYMNAL_DB || { hymns: [] };
const hymns = db.hymns || [];
// Scenic backgrounds — one assigned per hymn (stable per hymn, varies song to song).
const BGS = ['bg01','bg02','bg03','bg04','bg05','bg06','bg07','bg08','bg09','bg10','bg11','bg12','bg13','bg14','bg15','bg16','bg17','bg18','bg19','bg20','bg21','bg22','bg23','bg24','bg25','bg26','bg27','bg28','bg29','bg30','bg31','bg32','bg33','bg34','bg35','bg36','bg37','bg38','bg39','bg40','bg41','bg42','bg43','bg44','bg45','bg46','bg47','bg48','bg49','bg50']
.map(n => 'app/bg/'+n+'.jpg');
const [sel, setSel] = React.useState(0); // index into hymns
const [vi, setVi] = React.useState(0); // position within the play sequence
const [q, setQ] = React.useState('');
// "Planned" setlist — hymns chosen for a specific service, kept as an ORDERED
// list of hymn numbers so the worship team can arrange the sequence. It never
// alters the read-only hymnal database; add / remove / reorder only edit this list.
const [plannedList, setPlannedList] = React.useState(()=>{
try{ const a = JSON.parse(localStorage.getItem('cwa-hymnal-planned')||'[]'); return Array.isArray(a)?a:[]; }catch(e){ return []; }
});
const plannedSet = React.useMemo(()=> new Set(plannedList), [plannedList]);
const [plannedViewRaw, setPlannedView] = React.useState(false);
const plannedView = canPlan && plannedViewRaw; // Planned is an admin-only tool
React.useEffect(()=>{ localStorage.setItem('cwa-hymnal-planned', JSON.stringify(plannedList)); }, [plannedList]);
const togglePlan = (h)=> setPlannedList(prev=> prev.includes(h.number) ? prev.filter(n=>n!==h.number) : [...prev, h.number]);
const movePlan = (num, dir)=> setPlannedList(prev=>{ const a=[...prev]; const i=a.indexOf(num); const j=i+dir; if(i<0||j<0||j>=a.length) return prev; [a[i],a[j]]=[a[j],a[i]]; return a; });
const clearPlan = ()=>{ if(window.confirm('Clear all planned hymns? This only empties the plan — your hymnal is untouched.')) setPlannedList([]); };
// fontScale is a COMFORT multiplier on top of the auto-fit: 1 = fill the
// stage edge-to-edge, lower = leave a margin. The auto-fit itself sizes the
// lyrics to whatever screen runs the app (phone → tablet → desktop → 4K TV),
// so this only nudges how big they sit within that space.
const [fontScale, setFontScale] = React.useState(()=>{ let v=parseFloat(localStorage.getItem('cwa-hymnal-fs')||'0.92'); if(!(v>0)) v=0.92; return Math.min(1,Math.max(0.55,v)); });
const [repeatChorus, setRepeatChorus] = React.useState(()=> localStorage.getItem('cwa-hymnal-repeat')!=='0');
const [showWords, setShowWords] = React.useState(false); // full-lyrics reading view
const [editing, setEditing] = React.useState(false); // edit-words mode
const [draft, setDraft] = React.useState([]); // working copy while editing
const bump = (d)=> setFontScale(s=>{ const v=Math.min(1,Math.max(0.55,+(s+d).toFixed(2))); localStorage.setItem('cwa-hymnal-fs', v); return v; });
React.useEffect(()=>{ localStorage.setItem('cwa-hymnal-repeat', repeatChorus?'1':'0'); }, [repeatChorus]);
const baseHymn = hymns[sel] || null;
// Merge any saved edits (config.hymnalEdits, keyed by hymn number) over the
// read-only database, so edited words project and read everywhere.
const hymn = React.useMemo(()=>{
if(!baseHymn) return null;
const e = edits && edits[baseHymn.number];
return (e && Array.isArray(e.verses)) ? { ...baseHymn, verses: e.verses } : baseHymn;
}, [baseHymn, edits]);
const isEdited = !!(baseHymn && edits && edits[baseHymn.number]);
// pick a background by hymn number so each hymn keeps its own scene
const bgUrl = hymn ? BGS[((hymn.number || sel) % BGS.length + BGS.length) % BGS.length] : null;
// Build the order the hymn is actually SUNG. Every hymn opens with a TITLE page
// (number · title · author), then the verses. When a hymn has exactly one chorus,
// project it again after every verse (Title → Verse 1 → Chorus → Verse 2 → Chorus …).
// If the chorus is already repeated, or there are several choruses, keep the source order.
const isChorus = (v)=> !!v && (v.type==='chorus' || /chorus|refrain/i.test(v.label||''));
const verses = React.useMemo(()=>{
if(!hymn) return [];
const titlePage = { _title:true, label:'', lines:[] };
const items = hymn.verses || [];
let body = items;
if(repeatChorus){
const choruses = items.filter(isChorus);
const versesOnly = items.filter(v=>!isChorus(v));
if(choruses.length === 1 && versesOnly.length >= 1){
const c = choruses[0];
body = [];
versesOnly.forEach(v=>{ body.push(v); body.push(c); });
}
}
return [titlePage, ...body];
}, [hymn, repeatChorus]);
const count = verses.length;
const verse = verses[Math.min(vi, Math.max(0,count-1))] || null;
// keep latest count for the keyboard handler without re-binding constantly
const countRef = React.useRef(count);
countRef.current = count;
// clamp position if the sequence length changes (e.g. toggling chorus repeat)
React.useEffect(()=>{ setVi(v=> Math.min(v, Math.max(0,count-1))); }, [count]);
// always start at the TITLE slide (index 0) whenever the shown hymn changes —
// keyed on the hymn number so re-selecting or editing the same hymn also
// snaps back to the Title, not wherever we last were.
React.useEffect(()=>{ setVi(0); setEditing(false); }, [sel, hymn && hymn.number]);
// Auto-fit: scale the verse to fit the stage's SAFE area — i.e. the band
// between the title row (top) and the nav controls (bottom) — so the lyrics
// never overlap or get clipped by the chrome, on any screen size.
const stageRef = React.useRef(null);
const innerRef = React.useRef(null);
const safeRef = React.useRef(null);
const measureRef = React.useRef(null);
React.useEffect(()=>()=>{ if(measureRef.current){ measureRef.current.remove(); measureRef.current=null; } }, []);
const [isStageFs, setIsStageFs] = React.useState(false);
React.useLayoutEffect(()=>{
const esc = (s)=> String(s==null?'':s).replace(/&/g,'&').replace(//g,'>');
const verseHTML = (v)=>{
if(v && v._title){
return '
'
+ (hymn && hymn.number!=null ? '
Hymn '+esc(hymn.number)+'
' : '')
+ '
'+esc(hymn?hymn.title:'')+'
'
+ (hymn && hymn.author ? '
'+esc(hymn.author)+'
' : '')
+ '
';
}
const lines = ((v&&v.lines)||[]).map(ln=> '
'+(ln?esc(ln):'\u00a0')+'
').join('');
return '
'+esc(v&&v.label)+'
'+lines+'
';
};
const fit = ()=>{
const safe = safeRef.current, inner = innerRef.current;
if(!safe || !inner) return;
inner.style.transform = 'scale(1)';
const availH = safe.clientHeight, availW = safe.clientWidth;
if(availH<=0 || availW<=0) return;
// Measure EVERY slide of this hymn in a hidden clone so all the verses
// share ONE size — the text stays uniform as you move through the hymn.
// (Each hymn still gets its own size; the title slide fits on its own.)
let m = measureRef.current;
if(!m || m.parentNode!==safe){ if(m) m.remove(); m=document.createElement('div'); m.className='proj-inner'; m.style.cssText='position:absolute;left:-99999px;top:0;visibility:hidden;pointer-events:none;transform:none'; safe.appendChild(m); measureRef.current=m; }
let fillVerses = Infinity, fillTitle = null;
for(const v of verses){
m.innerHTML = verseHTML(v);
const nH = m.scrollHeight, nW = m.scrollWidth;
if(nH<=0 || nW<=0) continue;
const f = Math.min(availH/nH, availW/nW);
if(v && v._title) fillTitle = f; else fillVerses = Math.min(fillVerses, f);
}
let fill;
if(verse && verse._title) fill = (fillTitle!=null ? fillTitle : (isFinite(fillVerses)?fillVerses:1));
else fill = isFinite(fillVerses) ? fillVerses : (fillTitle!=null?fillTitle:1);
if(!(fill>0)){ const nH=inner.scrollHeight,nW=inner.scrollWidth; fill=(nH>0&&nW>0)?Math.min(availH/nH,availW/nW):1; }
const hardMax = isStageFs ? 7 : 6; // sanity cap for 1–2 word slides
let s = Math.min(fill * fontScale, fill * 0.98, hardMax);
s = Math.max(0.06, s);
inner.style.transform = 'scale('+s+')';
};
fit();
requestAnimationFrame(fit);
// re-fit after the serif web-font loads (changes text height) and on resize
const t1 = setTimeout(fit, 120);
const t2 = setTimeout(fit, 600);
if(document.fonts && document.fonts.ready) document.fonts.ready.then(fit).catch(()=>{});
window.addEventListener('resize', fit);
let ro = null;
if(window.ResizeObserver && safeRef.current){ ro = new ResizeObserver(fit); ro.observe(safeRef.current); }
return ()=>{ clearTimeout(t1); clearTimeout(t2); window.removeEventListener('resize', fit); if(ro) ro.disconnect(); };
}, [sel, vi, fontScale, active, count, repeatChorus, isStageFs, verses]);
// Auto-hide the on-screen chrome (title + buttons + nav) so the lyrics own the
// screen. Any tap, pointer move, or navigation reveals it again for a few seconds.
const [chrome, setChrome] = React.useState(true);
const hideTimer = React.useRef(null);
const revealChrome = React.useCallback(()=>{
setChrome(true);
clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(()=> setChrome(false), 3500);
}, []);
React.useEffect(()=>{ if(active) revealChrome(); else { clearTimeout(hideTimer.current); setChrome(true); } }, [active, sel, vi, revealChrome]);
React.useEffect(()=>()=> clearTimeout(hideTimer.current), []);
// Project the stage by itself, full screen (hides list + header).
function toggleStageFs(){
const el = stageRef.current;
if(!el) return;
const cur = document.fullscreenElement || document.webkitFullscreenElement;
if(cur){ (document.exitFullscreen || document.webkitExitFullscreen || function(){}).call(document); }
else { (el.requestFullscreen || el.webkitRequestFullscreen || function(){}).call(el); }
}
React.useEffect(()=>{
const on = ()=> setIsStageFs(!!(document.fullscreenElement || document.webkitFullscreenElement));
document.addEventListener('fullscreenchange', on);
document.addEventListener('webkitfullscreenchange', on);
return ()=>{ document.removeEventListener('fullscreenchange', on); document.removeEventListener('webkitfullscreenchange', on); };
}, []);
const filtered = React.useMemo(()=>{
const s = q.trim().toLowerCase();
const match = (h)=> !s || (h.title||'').toLowerCase().includes(s) || String(h.number)===s || String(h.number).startsWith(s);
if(plannedView){
// keep the planned ORDER so the team sees their service sequence
return plannedList
.map(num=>{ const i = hymns.findIndex(h=>h.number===num); return i>=0 ? { h:hymns[i], i } : null; })
.filter(it=> it && match(it.h));
}
return hymns.map((h,i)=>({ h, i })).filter(({h})=> match(h));
}, [q, hymns, plannedView, plannedList]);
function pick(i){ setSel(i); setVi(0); }
const go = (n)=> setVi(Math.min(Math.max(n,0), Math.max(0,count-1)));
// From the full-lyrics view, tap a verse to project it on the stage.
const jumpToVerse = (v)=>{ const i = verses.indexOf(v); setVi(i>=0?i:0); setShowWords(false); revealChrome(); };
// ----- Edit the words of the selected hymn (admin) -----
const startEdit = ()=>{ setDraft((hymn.verses||[]).map(v=>({ label:v.label||'', text:(v.lines||[]).join('\n') }))); setEditing(true); };
const cancelEdit = ()=> setEditing(false);
const saveEdit = ()=>{
if(!hymn || !setEdits) return;
const v = draft
.map(d=>({ type:/chorus|refrain/i.test(d.label)?'chorus':'verse', label:(d.label||'').trim(), lines:(d.text||'').replace(/\r/g,'').split('\n') }))
.filter(x=> x.label || x.lines.some(l=>l.trim()));
setEdits(prev=>({ ...(prev||{}), [hymn.number]: { verses:v } }));
setEditing(false);
setVi(0); // return to the Title slide after editing the words
};
const resetEdit = ()=>{ if(!hymn || !setEdits) return; setEdits(prev=>{ const n={ ...(prev||{}) }; delete n[hymn.number]; return n; }); setEditing(false); };
// Renumber only verse labels (Verse 1, 2, 3…) by their order; leave choruses,
// refrains and any custom labels exactly as the user named them.
const renumber = (arr)=>{ let n=0; return arr.map(d=>{ const lab=d.label||''; if(/chorus|refrain/i.test(lab)) return d; if(/^\s*verse\b/i.test(lab)){ n++; return { ...d, label:'Verse '+n }; } return d; }); };
const dUpd = (i,patch)=> setDraft(d=> d.map((x,k)=> k===i?{ ...x, ...patch }:x));
const dAdd = ()=> setDraft(d=> renumber([...d, { label:'Verse 0', text:'' }]));
const dDel = (i)=> setDraft(d=> renumber(d.filter((_,k)=> k!==i)));
const dMove = (i,dir)=> setDraft(d=>{ const a=[...d]; const j=i+dir; if(j<0||j>=a.length) return d; [a[i],a[j]]=[a[j],a[i]]; return renumber(a); });
// PowerPoint-style keyboard navigation (only while this tab is open)
React.useEffect(()=>{
if(!active) return;
const onKey = (e)=>{
if(/input|textarea|select/i.test(e.target.tagName) || e.target.isContentEditable) return;
const c = countRef.current;
const nav = ()=> revealChrome();
if(e.key==='ArrowRight'||e.key==='PageDown'||e.key==='ArrowDown'||e.key===' '||e.key==='Enter'){ e.preventDefault(); setVi(p=>Math.min(c-1,p+1)); nav(); }
else if(e.key==='ArrowLeft'||e.key==='PageUp'||e.key==='ArrowUp'){ e.preventDefault(); setVi(p=>Math.max(0,p-1)); nav(); }
else if(e.key==='Home'){ e.preventDefault(); setVi(0); nav(); }
else if(e.key==='End'){ e.preventDefault(); setVi(c-1); nav(); }
};
window.addEventListener('keydown', onKey);
return ()=> window.removeEventListener('keydown', onKey);
}, [active]);
return (
)}
{/* Full-lyrics reading view — all verses of the selected hymn, scrollable.
Tap any verse to project it. */}
{showWords && hymn && (
e.stopPropagation()}>
{hymn.number} · {hymn.title}{isEdited && · edited}Tap a verse to project it
{canPlan && setEdits && }
{(hymn.verses||[]).map((v,i)=>(
jumpToVerse(v)}>
{v.label &&
{v.label}
}
{(v.lines||[]).map((ln,j)=>(
{ln || '\u00a0'}
))}
))}
)}
{/* Tap zones (touch devices): left half = previous, right half = next.
Sit above the lyrics (z-2) but below the chrome buttons (z-3) so the
on-screen controls still work. Hidden on hover/mouse desktops. */}
{count>1 && (
)}