"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlignmentType, Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx"; type TabId = "raw" | "organized" | "assistant"; type ScriptureCard = { id: string; ref: string; text: string; source: "detected" | "suggested"; confidence?: number; reason?: string; }; type ChatEntry = { id: string; role: "user" | "assistant"; markdown: string; }; type CadencePoint = { tSec: number; wpm: number; }; type SpeechLanguage = "auto" | "english" | "spanish" | "french" | "portuguese" | "german" | "swahili" | "twi" | "kikuyu"; type PulpitBlock = { kind: "paragraph" | "bullet" | "quote" | "subheading"; text: string; }; type PulpitSection = { title: string; body: string; blocks: PulpitBlock[]; wordCount: number; }; type SermonCloudSnapshot = { rawTranscript: string; organizedMarkdown: string; manualNotes: string; scriptureCards: ScriptureCard[]; }; type SermonProjectRecord = { id: string; name: string; createdAt: string; updatedAt: string; sermonAssistant: SermonCloudSnapshot; }; type SermonApiResponse = { markdown: string; }; function normalizeSermonProjectRecord(input: unknown): SermonProjectRecord | null { if (!input || typeof input !== "object") return null; const record = input as Record; const rawSermon = record.sermonAssistant; if (!rawSermon) return null; const decoded = (() => { if (typeof rawSermon === "string") { try { return JSON.parse(rawSermon) as unknown; } catch { return null; } } return rawSermon; })(); if (!decoded || typeof decoded !== "object") return null; const sermon = decoded as Record; const scriptureCards = Array.isArray(sermon.scriptureCards) ? sermon.scriptureCards.filter((card): card is ScriptureCard => ( !!card && typeof card === "object" && typeof (card as Record).id === "string" && typeof (card as Record).ref === "string" && typeof (card as Record).text === "string" && (((card as Record).source === "detected") || ((card as Record).source === "suggested")) )) : []; const id = typeof record.id === "string" && record.id.trim() ? record.id : `sermon-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const name = typeof record.name === "string" && record.name.trim() ? record.name : "Sermon"; const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString(); const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt; return { id, name, createdAt, updatedAt, sermonAssistant: { rawTranscript: typeof sermon.rawTranscript === "string" ? sermon.rawTranscript : "", organizedMarkdown: typeof sermon.organizedMarkdown === "string" ? sermon.organizedMarkdown : "", manualNotes: typeof sermon.manualNotes === "string" ? sermon.manualNotes : "", scriptureCards, }, }; } type SuggestionResponse = { suggestions: Array<{ ref: string; text: string; reason?: string; confidence?: number; }>; }; const STORAGE_KEYS = { raw: "nexus_sermon_raw", organized: "nexus_sermon_organized", notes: "nexus_sermon_manual_notes", projectId: "nexus_sermon_project_id", projectName: "nexus_sermon_project_name", } as const; const SCRIPTURE_DB = [ { triggers: ["john 3 16", "john 316", "john three sixteen", "so loved the world", "gave his only son", "gave his only begotten son"], ref: "John 3:16", text: "For God so loved the world, that he gave his only Son, that whoever believes in him should not perish but have eternal life." }, { triggers: ["ephesians 6", "armor of god", "armour of god", "whole armor", "put on the full armor"], ref: "Ephesians 6:11", text: "Put on the whole armor of God, that you may be able to stand against the schemes of the devil." }, { triggers: ["psalm 23", "lord is my shepherd", "i shall not want", "he leadeth me"], ref: "Psalm 23:1", text: "The Lord is my shepherd; I shall not want." }, { triggers: ["john 1 1", "in the beginning was the word", "word was god", "word was with god", "the word was god"], ref: "John 1:1", text: "In the beginning was the Word, and the Word was with God, and the Word was God." }, { triggers: ["genesis chapter one", "genesis 1", "genesis one", "in the beginning god created", "in the beginning god", "god created the heavens", "created the heavens and the earth"], ref: "Genesis 1:1", text: "In the beginning, God created the heavens and the earth." }, { triggers: ["romans 8 28", "all things work together", "all things work together for good", "work together for those who love god"], ref: "Romans 8:28", text: "And we know that for those who love God all things work together for good, for those who are called according to his purpose." }, { triggers: ["philippians 4 13", "i can do all things", "all things through christ", "through christ who strengthens me"], ref: "Philippians 4:13", text: "I can do all things through him who strengthens me." }, { triggers: ["jeremiah 29 11", "plans i have for you", "plans to prosper you", "plans for welfare and not evil"], ref: "Jeremiah 29:11", text: "For I know the plans I have for you, declares the Lord, plans for welfare and not for evil, to give you a future and a hope." }, { triggers: ["joshua 1 9", "be strong and courageous", "be strong and of good courage", "do not be afraid or dismayed"], ref: "Joshua 1:9", text: "Be strong and courageous. Do not be frightened, and do not be dismayed, for the Lord your God is with you wherever you go." }, { triggers: ["proverbs 3 5", "trust in the lord with all your heart", "lean not on your own understanding", "lean not unto thine own understanding"], ref: "Proverbs 3:5", text: "Trust in the Lord with all your heart, and do not lean on your own understanding." }, { triggers: ["isaiah 40 31", "those who wait upon the lord", "they shall renew their strength", "mount up with wings as eagles", "wings like eagles"], ref: "Isaiah 40:31", text: "But they who wait for the Lord shall renew their strength; they shall mount up with wings like eagles; they shall run and not be weary." }, { triggers: ["john 14 6", "i am the way the truth and the life", "the way the truth and the life", "i am the way"], ref: "John 14:6", text: "Jesus said to him, 'I am the way, and the truth, and the life. No one comes to the Father except through me.'" }, { triggers: ["matthew 6 33", "seek first the kingdom", "seek ye first the kingdom", "seek first his kingdom and his righteousness"], ref: "Matthew 6:33", text: "But seek first the kingdom of God and his righteousness, and all these things will be added to you." }, { triggers: ["2 chronicles 7 14", "second chronicles 7 14", "if my people who are called by my name", "humble themselves and pray", "turn from their wicked ways"], ref: "2 Chronicles 7:14", text: "If my people who are called by my name humble themselves, and pray and seek my face and turn from their wicked ways, then I will hear from heaven and will forgive their sin and heal their land." }, { triggers: ["revelation 3 20", "behold i stand at the door and knock", "i stand at the door", "knock and i will open"], ref: "Revelation 3:20", text: "Behold, I stand at the door and knock. If anyone hears my voice and opens the door, I will come in to him and eat with him, and he with me." }, ] as const; const SCRIPTURE_REF_REGEX = /\b(?:[1-3]\s+)?[A-Za-z]+(?:\s+[A-Za-z]+)?\s+\d{1,3}:\d{1,3}(?:-\d{1,3})?\b/g; const BIBLE_REF_REGEX = /\b(?:[1-3]\s+)?(?:[A-Z][a-z]+(?:\s+[A-Za-z]+){0,2})\s+\d{1,3}:\d{1,3}(?:-\d{1,3})?\b/g; const READING_INTENT_REGEX = /\b(?:i(?:'m| am) (?:now )?reading|(?:let(?:'s| us)) (?:read|open to|turn to)|(?:our|today(?:'s)?|this) (?:text|scripture|passage) (?:is|comes from)|(?:open|turn) (?:your )?(?:bibles? )?to|reading (?:from|with me)|follow(?:ing)? along)\b/i; const NEXT_VERSE_REGEX = /\bnext verse\b/i; const VERSE_NUMBER_REGEX = /\bverse\s+(\d+)\b/i; const NUM_WORDS: Record = { one:1,two:2,three:3,four:4,five:5,six:6,seven:7,eight:8,nine:9, ten:10,eleven:11,twelve:12,thirteen:13,fourteen:14,fifteen:15, sixteen:16,seventeen:17,eighteen:18,nineteen:19, twenty:20,thirty:30,forty:40,fifty:50,sixty:60,seventy:70,eighty:80,ninety:90, }; function wordsToDigits(s: string): string { return s .replace(/\b(twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)[\s-]?(one|two|three|four|five|six|seven|eight|nine)\b/gi, (_, t: string, o: string) => String((NUM_WORDS[t.toLowerCase()] ?? 0) + (NUM_WORDS[o.toLowerCase()] ?? 0))) .replace(/\b(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)\b/gi, (w) => String(NUM_WORDS[w.toLowerCase()] ?? w)); } function extractSpokenRefs(raw: string): string[] { let s = raw; s = s.replace(/\bfirst\s+([A-Z])/g, "1 $1").replace(/\bsecond\s+([A-Z])/g, "2 $1").replace(/\bthird\s+([A-Z])/g, "3 $1"); s = wordsToDigits(s); s = s.replace(/\bchapter\s+(\d+)[,\s]+(?:and\s+)?verse\s+(\d+)/gi, "$1:$2"); s = s.replace(/\b(\d+)\s+verse\s+(\d+)/gi, "$1:$2"); const spaceRefs = s.match(/\b(?:[1-3]\s+)?[A-Z][a-z]+(?:\s+[A-Za-z]+)?\s+(\d{1,3})\s+(\d{1,3})\b/g) ?? []; const spaceFixed = spaceRefs.map((r) => r.replace(/(\d+)\s+(\d+)$/, "$1:$2")); const standard = s.match(/\b(?:[1-3]\s+)?[A-Z][a-z]+(?:\s+[A-Za-z]+)?\s+\d{1,3}:\d{1,3}(?:-\d{1,3})?\b/g) ?? []; return [...new Set([...standard, ...spaceFixed])]; } function verseOverlap(verseText: string, spokenText: string): number { if (!verseText || !spokenText) return 0; const words = verseText.toLowerCase().split(/\s+/).filter((w) => w.length > 3); if (words.length === 0) return 0; const spoken = spokenText.toLowerCase(); const matched = words.filter((w) => spoken.includes(w)); return matched.length / words.length; } function nextSequentialRef(ref: string): string | null { const m = ref.match(/^((?:[1-3]\s+)?[A-Za-z]+(?:\s+[A-Za-z]+)?)\s+(\d+):(\d+)$/); if (!m) return null; return `${m[1]} ${m[2]}:${parseInt(m[3]) + 1}`; } const THEOLOGY_HINTS = [ "god", "lord", "jesus", "christ", "holy spirit", "holy ghost", "scripture", "bible", "verse", "gospel", "word of god", "genesis", "exodus", "leviticus", "numbers", "deuteronomy", "joshua", "judges", "samuel", "kings", "chronicles", "psalms", "psalm", "proverbs", "isaiah", "jeremiah", "ezekiel", "daniel", "matthew", "mark", "luke", "john", "acts", "romans", "corinthians", "galatians", "ephesians", "philippians", "colossians", "thessalonians", "timothy", "revelation", "prophet", "covenant", "dry bones", "cross", "resurrection", "shepherd", "wilderness", "promise", "kingdom", "grace", "mercy", "faith", "israel", "paul", "moses", "david", "heaven", "salvation", "sin", "forgiveness", "prayer", "church", "spirit", "amen", "blessed", "glory", ] as const; function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function renderMarkdown(md: string): string { const escaped = escapeHtml(md); let html = escaped.replace(/^### (.*$)/gim, '

$1

'); html = html.replace(/^## (.*$)/gim, '

$1

'); html = html.replace(/^# (.*$)/gim, '

$1

'); html = html.replace(/^\> (.*$)/gim, '
$1
'); html = html.replace(/^\-\s+(.*$)/gim, '
  • $1
  • '); html = html.replace(/\*\*(.*?)\*\*/gim, '$1'); html = html.replace(/\*(.*?)\*/gim, "$1"); html = html.replace(/\n/g, "
    "); return html; } function stripMarkdown(md: string): string { return md .replace(/^#{1,6}\s+/gm, "") .replace(/^>\s?/gm, "") .replace(/^[-*]\s+/gm, "") .replace(/\*\*/g, "") .replace(/\*/g, "") .replace(/`/g, "") .trim(); } function normalizeForTriggers(text: string): string { return text.toLowerCase().replace(/[.,!?;:]/g, ""); } function countWords(text: string): number { const clean = text.trim(); if (!clean) return 0; return clean.split(/\s+/).filter(Boolean).length; } function chooseRecorderMimeType(): string { const candidates = [ "audio/webm;codecs=opus", "audio/webm", "audio/mp4", ]; for (const candidate of candidates) { if (MediaRecorder.isTypeSupported(candidate)) return candidate; } return ""; } function buildPulpitSections(markdown: string): PulpitSection[] { function linesToBlocks(bodyLines: string[]): PulpitBlock[] { const blocks: PulpitBlock[] = []; let paragraphBuffer: string[] = []; const flushParagraph = () => { const text = stripMarkdown(paragraphBuffer.join(" ")).trim(); if (text) blocks.push({ kind: "paragraph", text }); paragraphBuffer = []; }; for (const rawLine of bodyLines) { const line = rawLine.trim(); if (!line) { flushParagraph(); continue; } if (line.startsWith("### ")) { flushParagraph(); blocks.push({ kind: "subheading", text: stripMarkdown(line) }); continue; } if (line.startsWith(">")) { flushParagraph(); blocks.push({ kind: "quote", text: stripMarkdown(line) }); continue; } if (/^[-*]\s+/.test(line) || /^\d+\.\s+/.test(line)) { flushParagraph(); blocks.push({ kind: "bullet", text: stripMarkdown(line) }); continue; } paragraphBuffer.push(line); } flushParagraph(); return blocks; } const lines = markdown.split(/\r?\n/); const sections: PulpitSection[] = []; let currentTitle = "Sermon"; let bodyLines: string[] = []; const pushSection = () => { const body = stripMarkdown(bodyLines.join("\n")).trim(); if (!body) return; sections.push({ title: currentTitle, body, blocks: linesToBlocks(bodyLines), wordCount: countWords(body), }); }; for (const line of lines) { const heading = line.match(/^(#{1,2})\s+(.+)$/); if (heading) { pushSection(); currentTitle = heading[2].trim(); bodyLines = []; continue; } bodyLines.push(line); } pushSection(); if (sections.length === 0 && markdown.trim()) { const body = stripMarkdown(markdown); return [{ title: "Sermon", body, blocks: linesToBlocks(markdown.split(/\r?\n/)), wordCount: countWords(body) }]; } return sections; } function formatClockTime(date: Date): string { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function formatElapsed(seconds: number): string { const s = Math.max(0, Math.floor(seconds)); const hh = Math.floor(s / 3600); const mm = Math.floor((s % 3600) / 60); const ss = s % 60; if (hh > 0) { return `${hh.toString().padStart(2, "0")}:${mm.toString().padStart(2, "0")}:${ss.toString().padStart(2, "0")}`; } return `${mm.toString().padStart(2, "0")}:${ss.toString().padStart(2, "0")}`; } function estimateSectionDuration(wordCount: number, wpm: number): string { const effectiveWpm = wpm >= 80 ? wpm : 130; const minutes = Math.max(1, Math.round((wordCount / effectiveWpm) * 60)); return formatElapsed(minutes); } function looksTheological(text: string): boolean { const lowered = text.toLowerCase(); return THEOLOGY_HINTS.some((hint) => lowered.includes(hint)); } function normalizeRef(ref: string): string { return ref.replace(/\s+/g, " ").trim(); } function extractScriptureRefs(text: string): string[] { const matches = text.match(SCRIPTURE_REF_REGEX) ?? []; const unique = new Set(); for (const raw of matches) { const normalized = normalizeRef(raw); if (normalized.length >= 4) unique.add(normalized); } return [...unique]; } function slugifyFileName(input: string): string { const normalized = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return normalized || "sermon-notes"; } function buildDocxParagraphs(markdown: string): Paragraph[] { const lines = markdown.split(/\r?\n/); const paragraphs: Paragraph[] = []; for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { paragraphs.push(new Paragraph({ text: "" })); continue; } if (line.startsWith("### ")) { paragraphs.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 })); continue; } if (line.startsWith("## ")) { paragraphs.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 })); continue; } if (line.startsWith("# ")) { paragraphs.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 })); continue; } if (/^[-*]\s+/.test(line)) { paragraphs.push(new Paragraph({ text: line.replace(/^[-*]\s+/, ""), bullet: { level: 0 }, })); continue; } if (line.startsWith("> ")) { paragraphs.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], })); continue; } paragraphs.push(new Paragraph({ text: line })); } return paragraphs; } export function SermonAssistantPanel() { const [activeTab, setActiveTab] = useState("raw"); const [rawTranscript, setRawTranscript] = useState(""); const [organizedMarkdown, setOrganizedMarkdown] = useState(""); const [manualNotes, setManualNotes] = useState(""); const [scriptureCards, setScriptureCards] = useState([]); const [chatEntries, setChatEntries] = useState([]); const [assistantInput, setAssistantInput] = useState(""); const [isRecording, setIsRecording] = useState(false); const [interimText, setInterimText] = useState(""); const [statusText, setStatusText] = useState("Ready"); const [toast, setToast] = useState<{ text: string; type: "info" | "success" | "error" } | null>(null); const [isGenerating, setIsGenerating] = useState(false); const [isAssistantThinking, setIsAssistantThinking] = useState(false); const [currentProjectId, setCurrentProjectId] = useState(""); const [projectName, setProjectName] = useState(""); const [historyOpen, setHistoryOpen] = useState(false); const [historyItems, setHistoryItems] = useState([]); const [isEditingOrganized, setIsEditingOrganized] = useState(false); const [isCompactLayout, setIsCompactLayout] = useState(false); const [mobileToolsOpen, setMobileToolsOpen] = useState(false); const [mobileRefsOpen, setMobileRefsOpen] = useState(false); const [mobileTelemetryOpen, setMobileTelemetryOpen] = useState(false); const [desktopTelemetryOpen, setDesktopTelemetryOpen] = useState(false); const [mobileOrganizedView, setMobileOrganizedView] = useState<"outline" | "manual">("outline"); const [speechLanguage, setSpeechLanguage] = useState("auto"); const [autoPushDisplay, setAutoPushDisplay] = useState(false); const [liveMode, setLiveMode] = useState(false); const [readingQueue, setReadingQueue] = useState>([]); const [readingQueueIndex, setReadingQueueIndex] = useState(0); const [bibleTranslation, setBibleTranslation] = useState<"web" | "kjv" | "asv" | "ylt" | "niv" | "nlt" | "nkjv" | "amp" | "msg">("kjv"); const [lastDisplayRef, setLastDisplayRef] = useState(""); const [refsPanelWidth, setRefsPanelWidth] = useState(300); const [isDraggingResize, setIsDraggingResize] = useState(false); const isDraggingResizeRef = useRef(false); const dragStartDataRef = useRef({ startX: 0, startWidth: 300 }); const containerRef = useRef(null); const [volumeLevel, setVolumeLevel] = useState(0); const [currentWpm, setCurrentWpm] = useState(0); const [avgWpm, setAvgWpm] = useState(0); const [cadencePoints, setCadencePoints] = useState([]); const [audioDownloadUrl, setAudioDownloadUrl] = useState(null); const [audioFileName, setAudioFileName] = useState("sermon-session.webm"); const [pulpitOpen, setPulpitOpen] = useState(false); const [pulpitIndex, setPulpitIndex] = useState(0); const [pulpitNow, setPulpitNow] = useState(() => Date.now()); const [pulpitStartedAt, setPulpitStartedAt] = useState(null); const recorderRef = useRef(null); const streamRef = useRef(null); const socketRef = useRef(null); const fileRef = useRef(null); const chatScrollRef = useRef(null); const transcriptScrollRef = useRef(null); const audioChunksRef = useRef([]); const audioMimeRef = useRef("audio/webm"); const audioContextRef = useRef(null); const analyserRef = useRef(null); const volumeFrameRef = useRef(null); const recordingStartedAtRef = useRef(null); const lastFinalAtRef = useRef(null); const totalWordsRef = useRef(0); const totalSpeechSecondsRef = useRef(0); const semanticTimerRef = useRef(null); const semanticInFlightRef = useRef(false); const scriptureCardsRef = useRef([]); const presentationWindowRef = useRef(null); const pulpitTouchStartXRef = useRef(null); const readingQueueRef = useRef>([]); const readingQueueIndexRef = useRef(0); const liveModeRef = useRef(false); const bibleTranslationRef = useRef<"web" | "kjv" | "asv" | "ylt" | "niv" | "nlt" | "nkjv" | "amp" | "msg">("kjv"); const lastMonitorRefRef = useRef(""); const wordsSpokenForVerseRef = useRef(""); const tabOrder = isCompactLayout ? (["organized", "raw", "assistant"] as TabId[]) : (["raw", "organized", "assistant"] as TabId[]); const getPreferredLandingTab = useCallback((organizedValue: string): TabId => { if (isCompactLayout && organizedValue.trim()) return "organized"; return "raw"; }, [isCompactLayout]); const mergeScriptureCards = useCallback((incoming: ScriptureCard[]) => { if (incoming.length === 0) return; setScriptureCards((prev) => { const existingRefs = new Set(prev.map((card) => card.ref.toLowerCase())); const additions = incoming.filter((card) => !existingRefs.has(card.ref.toLowerCase())); return additions.length === 0 ? prev : [...additions, ...prev]; }); }, []); useEffect(() => { const media = window.matchMedia("(max-width: 1023px)"); const syncLayout = (event?: MediaQueryList | MediaQueryListEvent) => { setIsCompactLayout((event ?? media).matches); }; syncLayout(media); const onChange = (event: MediaQueryListEvent) => { syncLayout(event); }; media.addEventListener("change", onChange); return () => media.removeEventListener("change", onChange); }, []); useEffect(() => { const storedRaw = localStorage.getItem(STORAGE_KEYS.raw) ?? ""; const storedOrganized = localStorage.getItem(STORAGE_KEYS.organized) ?? ""; setRawTranscript(storedRaw); setOrganizedMarkdown(storedOrganized); setManualNotes(localStorage.getItem(STORAGE_KEYS.notes) ?? ""); setCurrentProjectId(localStorage.getItem(STORAGE_KEYS.projectId) ?? ""); setProjectName(localStorage.getItem(STORAGE_KEYS.projectName) ?? ""); setActiveTab(getPreferredLandingTab(storedOrganized)); }, [getPreferredLandingTab]); useEffect(() => { if (!isCompactLayout || !organizedMarkdown.trim()) return; setActiveTab((current) => (current === "raw" ? "organized" : current)); }, [isCompactLayout, organizedMarkdown]); useEffect(() => { scriptureCardsRef.current = scriptureCards; }, [scriptureCards]); useEffect(() => { readingQueueRef.current = readingQueue; }, [readingQueue]); useEffect(() => { readingQueueIndexRef.current = readingQueueIndex; }, [readingQueueIndex]); useEffect(() => { liveModeRef.current = liveMode; }, [liveMode]); useEffect(() => { bibleTranslationRef.current = bibleTranslation; }, [bibleTranslation]); useEffect(() => { const applyDrag = (clientX: number) => { if (!isDraggingResizeRef.current) return; const dx = dragStartDataRef.current.startX - clientX; const newW = Math.max(220, Math.min(620, dragStartDataRef.current.startWidth + dx)); setRefsPanelWidth(newW); }; const onMove = (e: MouseEvent) => applyDrag(e.clientX); const onTouchMove = (e: TouchEvent) => { if (e.touches[0]) applyDrag(e.touches[0].clientX); }; const onUp = () => { if (!isDraggingResizeRef.current) return; isDraggingResizeRef.current = false; setIsDraggingResize(false); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); document.addEventListener("touchmove", onTouchMove, { passive: false }); document.addEventListener("touchend", onUp); return () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); document.removeEventListener("touchmove", onTouchMove); document.removeEventListener("touchend", onUp); }; }, []); useEffect(() => { const refs = extractScriptureRefs(`${rawTranscript}\n${organizedMarkdown}`); if (refs.length === 0) return; const known = new Map(); for (const row of SCRIPTURE_DB) known.set(row.ref.toLowerCase(), row.text); const cards: ScriptureCard[] = refs.map((ref) => ({ id: `${ref}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, ref, text: known.get(ref.toLowerCase()) ?? "Reference detected in your notes.", source: "detected", confidence: 1, })); mergeScriptureCards(cards); }, [mergeScriptureCards, organizedMarkdown, rawTranscript]); useEffect(() => { localStorage.setItem(STORAGE_KEYS.raw, rawTranscript); }, [rawTranscript]); useEffect(() => { localStorage.setItem(STORAGE_KEYS.organized, organizedMarkdown); }, [organizedMarkdown]); useEffect(() => { localStorage.setItem(STORAGE_KEYS.notes, manualNotes); }, [manualNotes]); useEffect(() => { if (currentProjectId) localStorage.setItem(STORAGE_KEYS.projectId, currentProjectId); }, [currentProjectId]); useEffect(() => { if (projectName) localStorage.setItem(STORAGE_KEYS.projectName, projectName); }, [projectName]); useEffect(() => { if (!toast) return; const timer = window.setTimeout(() => setToast(null), 2800); return () => window.clearTimeout(timer); }, [toast]); useEffect(() => { const el = chatScrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [chatEntries, isAssistantThinking]); useEffect(() => { const el = transcriptScrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [rawTranscript, interimText]); const transcriptPreview = useMemo(() => { if (!rawTranscript.trim() && !interimText.trim()) return ""; return `${rawTranscript}${interimText ? ` ${interimText}` : ""}`.trim(); }, [interimText, rawTranscript]); const speechLanguageLabel = useMemo(() => { const labels: Record = { auto: "Auto", english: "English", spanish: "Spanish", french: "French", portuguese: "Portuguese", german: "German", swahili: "Swahili", twi: "Twi", kikuyu: "Kikuyu", }; return labels[speechLanguage]; }, [speechLanguage]); const pulpitSections = useMemo(() => buildPulpitSections(organizedMarkdown), [organizedMarkdown]); const elapsedPulpitSec = useMemo(() => { if (!pulpitStartedAt) return 0; return Math.floor((pulpitNow - pulpitStartedAt) / 1000); }, [pulpitNow, pulpitStartedAt]); useEffect(() => { if (!pulpitOpen) return; const id = window.setInterval(() => setPulpitNow(Date.now()), 1000); return () => window.clearInterval(id); }, [pulpitOpen]); useEffect(() => { if (!pulpitOpen) return; const handleKeydown = (event: KeyboardEvent) => { if (event.key === "ArrowRight" || event.key === "PageDown" || event.key === " ") { event.preventDefault(); setPulpitIndex((prev) => Math.min(pulpitSections.length - 1, prev + 1)); } if (event.key === "ArrowLeft" || event.key === "PageUp") { event.preventDefault(); setPulpitIndex((prev) => Math.max(0, prev - 1)); } if (event.key === "Escape") { event.preventDefault(); setPulpitOpen(false); } }; window.addEventListener("keydown", handleKeydown); return () => window.removeEventListener("keydown", handleKeydown); }, [pulpitOpen, pulpitSections.length]); useEffect(() => { return () => { if (volumeFrameRef.current) window.cancelAnimationFrame(volumeFrameRef.current); if (audioContextRef.current) void audioContextRef.current.close(); if (semanticTimerRef.current) window.clearTimeout(semanticTimerRef.current); if (audioDownloadUrl) URL.revokeObjectURL(audioDownloadUrl); if (presentationWindowRef.current && !presentationWindowRef.current.closed) { presentationWindowRef.current.close(); } }; }, [audioDownloadUrl]); const pushToast = useCallback((text: string, type: "info" | "success" | "error" = "info") => { setToast({ text, type }); }, []); const deriveProjectName = useCallback(() => { if (projectName.trim()) return projectName.trim(); const heading = organizedMarkdown.match(/^#\s+(.+)$/m)?.[1]?.trim(); if (heading) return heading; const firstLine = rawTranscript.split(/\r?\n/).find((line) => line.trim())?.trim(); if (firstLine) return firstLine.slice(0, 48); return `Sermon ${new Date().toLocaleDateString()}`; }, [organizedMarkdown, projectName, rawTranscript]); const closeAudioNodes = useCallback(() => { if (volumeFrameRef.current) { window.cancelAnimationFrame(volumeFrameRef.current); volumeFrameRef.current = null; } if (audioContextRef.current) { void audioContextRef.current.close(); audioContextRef.current = null; } analyserRef.current = null; setVolumeLevel(0); }, []); const refreshAudioDownload = useCallback(() => { if (audioChunksRef.current.length === 0) return; if (audioDownloadUrl) URL.revokeObjectURL(audioDownloadUrl); const blob = new Blob(audioChunksRef.current, { type: audioMimeRef.current || "audio/webm" }); const nextUrl = URL.createObjectURL(blob); setAudioDownloadUrl(nextUrl); const ext = audioMimeRef.current.includes("mp4") ? "m4a" : "webm"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); setAudioFileName(`sermon-session-${stamp}.${ext}`); }, [audioDownloadUrl]); const stopRecording = useCallback(() => { if (recorderRef.current && recorderRef.current.state !== "inactive") recorderRef.current.stop(); if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } if (socketRef.current) { try { socketRef.current.close(); } catch { // ignore } socketRef.current = null; } closeAudioNodes(); setInterimText(""); setIsRecording(false); setStatusText("Paused"); refreshAudioDownload(); }, [closeAudioNodes, refreshAudioDownload]); const launchDisplay = useCallback(() => { const win = window.open("", "NexusScriptureDisplay", "width=1280,height=720,menubar=no,toolbar=no,location=no,status=no"); if (!win) { pushToast("Pop-up blocked. Allow pop-ups for this site.", "error"); return; } presentationWindowRef.current = win; win.document.write([ '', '', '', 'Scripture Display', '