// 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 (
{bgUrl &&
}
{hymn &&
{hymn.number} · {hymn.title}{hymn.author?' — '+hymn.author:''}
} {verse ? (
{verse._title ? (
{hymn && hymn.number!=null &&
Hymn {hymn.number}
}
{hymn ? hymn.title : ''}
{hymn && hymn.author &&
{hymn.author}
}
) : (
{verse.label}
{verse.lines.map((ln,i)=>(
{ln || '\u00a0'}
))}
)}
) : (
Select a hymn from the list to project its words.
)} {/* 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 && ( )}
{count>0 && (
{vi+1} / {count}
)}
{editing && hymn && (
{ if(e.currentTarget===e.target) cancelEdit(); }}>

Edit words — {hymn.number} · {hymn.title}

{draft.map((d,i)=>(
dUpd(i,{label:e.target.value})} placeholder="Label (e.g. Verse 1, Chorus)" />