import {
PresentationWaveform,
PresentationTranscript,
PresentationController,
PresentationSectionNav,
buildProject,
initPresThemeToggle,
} from './presentation.js';
// ── State ─────────────────────────────────────────────────────────────────────
const HEADER_H = 56;
let projects = []; // built Project instances
let currentIdx = 0;
let ctrl = null;
let presWaveform = null;
let presTranscript = null;
let presSectionNav = null;
let playerGroup = null;
// ── DOM refs ──────────────────────────────────────────────────────────────────
/**
* @param {string} id - Element ID to look up.
* @returns {HTMLElement}
*/
function el(id) { return document.getElementById(id); }
// ── State panels ──────────────────────────────────────────────────────────────
/** Opens the playlist sidebar panel. */
function openPlaylist() {
el('presPlaylist').classList.add('open');
document.body.classList.add('pres-playlist-open');
el('presPlaylistToggle').style.display = '';
if (window.innerWidth <= 640) {
el('presPlaylistBackdrop').style.display = '';
}
}
/** Closes the playlist sidebar panel. */
function closePlaylist() {
el('presPlaylist').classList.remove('open');
document.body.classList.remove('pres-playlist-open');
el('presPlaylistBackdrop').style.display = 'none';
}
/** @param {string} id - element id of the state panel to show */
function showState(id) {
['presLoading', 'presAuthRequired', 'presNoAccess', 'presEmpty'].forEach(s => {
el(s).style.display = (s === id) ? '' : 'none';
});
el('presFolderContent').style.display = 'none';
closePlaylist();
el('presPlaylistToggle').style.display = 'none';
}
/** Shows the main presentation content area and hides all state panels. */
function showPresentation() {
['presLoading', 'presAuthRequired', 'presNoAccess', 'presEmpty'].forEach(s => {
el(s).style.display = 'none';
});
el('presFolderContent').style.display = '';
el('presWaveformSection').style.display = '';
el('presTranscriptSection').style.display = '';
if (window.innerWidth > 640) {
openPlaylist();
} else {
el('presPlaylistToggle').style.display = '';
}
}
// ── Playlist ──────────────────────────────────────────────────────────────────
/** Renders the playlist nav items from the current projects array. */
function buildPlaylist() {
const nav = el('presPlaylist');
nav.innerHTML = '<div class="pres-playlist-header">Playlist</div>';
projects.forEach((project, idx) => {
const btn = document.createElement('button');
btn.className = 'pres-playlist-item';
btn.textContent = project.projectName;
btn.addEventListener('click', () => loadProject(idx));
nav.appendChild(btn);
});
}
/** Syncs the active class on playlist items to match currentIdx. */
function updatePlaylistActive() {
el('presPlaylist').querySelectorAll('.pres-playlist-item').forEach((btn, i) => {
btn.classList.toggle('active', i === currentIdx);
});
}
// ── Load project into panels ──────────────────────────────────────────────────
/** @param {number} idx - index into the projects array to load */
function loadProject(idx) {
currentIdx = idx;
const project = projects[idx];
ctrl.activeProject = project;
ctrl.activeSegmentIdx = -1;
ctrl.selectedSegmentIdx = -1;
presWaveform.loadFromProject(project);
presTranscript.loadFromProject(project);
presSectionNav.setSections(presTranscript.sections);
updatePlaylistActive();
el('presBreadcrumbProject').textContent = project.projectName;
el('presBreadcrumbSep').style.display = '';
el('presCopyProjectBtn').style.display = '';
window.scrollTo({ top: 0, behavior: 'smooth' });
if (window.innerWidth <= 640) closePlaylist();
}
// ── Init ──────────────────────────────────────────────────────────────────────
/** Initialises the search bar toggle, highlighting, and navigation controls. */
function initSearch() {
const searchBar = el('presSearchBar');
const toggleBtn = el('presSearchToggle');
const fields = el('presSearchFields');
const input = el('presSearchInput');
const countEl = el('presSearchCount');
const prevBtn = el('presSearchPrev');
const nextBtn = el('presSearchNext');
let matches = [];
let focusedIdx = -1;
/** Opens the search bar and focuses the input. */
function open() {
searchBar.classList.add('open');
fields.style.display = 'flex';
input.focus();
input.select();
}
/** Closes the search bar and clears all highlights. */
function close() {
searchBar.classList.remove('open');
fields.style.display = 'none';
_clearMatches();
input.value = '';
}
/** @param {HTMLElement} rootEl - element whose search highlights to remove */
function _clearTextHighlights(rootEl) {
rootEl.querySelectorAll('mark.pt-search-hl').forEach(mark => {
mark.replaceWith(document.createTextNode(mark.textContent));
});
rootEl.normalize();
}
/**
* @param {HTMLElement} rootEl - Root element to search within.
* @param {string} query - Lowercase search term to highlight.
*/
function _highlightTextInEl(rootEl, query) {
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null);
const textNodes = [];
let node;
while ((node = walker.nextNode())) textNodes.push(node);
textNodes.forEach(textNode => {
const text = textNode.textContent;
const lower = text.toLowerCase();
if (!lower.includes(query)) return;
const frag = document.createDocumentFragment();
let pos = 0, i = lower.indexOf(query, 0);
while (i !== -1) {
if (i > pos) frag.appendChild(document.createTextNode(text.slice(pos, i)));
const mark = document.createElement('mark');
mark.className = 'pt-search-hl';
mark.textContent = text.slice(i, i + query.length);
frag.appendChild(mark);
pos = i + query.length;
i = lower.indexOf(query, pos);
}
if (pos < text.length) frag.appendChild(document.createTextNode(text.slice(pos)));
textNode.parentNode.replaceChild(frag, textNode);
});
}
/** Clears all match highlights and resets navigation state. */
function _clearMatches() {
matches.forEach(idx => {
const segEl = presTranscript._segEls[idx];
if (!segEl) return;
segEl.classList.remove('pt-seg-search-focused');
_clearTextHighlights(segEl);
});
matches = [];
focusedIdx = -1;
countEl.textContent = '';
prevBtn.disabled = true;
nextBtn.disabled = true;
}
/** @param {number} i - wrapping index into the matches array */
function _focusMatch(i) {
if (!matches.length) return;
presTranscript._segEls[matches[focusedIdx]]?.classList.remove('pt-seg-search-focused');
focusedIdx = ((i % matches.length) + matches.length) % matches.length;
const segEl = presTranscript._segEls[matches[focusedIdx]];
segEl?.classList.add('pt-seg-search-focused');
segEl?.scrollIntoView({ behavior: 'smooth', block: 'center' });
countEl.textContent = `${focusedIdx + 1} / ${matches.length}`;
}
/** Scans transcript segments for the current query and highlights matches. */
function _runSearch() {
_clearMatches();
const q = input.value.trim().toLowerCase();
if (!q) return;
presTranscript._segEls.forEach((segEl, idx) => {
if (!segEl) return;
if (segEl.textContent.toLowerCase().includes(q)) {
_highlightTextInEl(segEl, q);
matches.push(idx);
}
});
if (matches.length) {
prevBtn.disabled = false;
nextBtn.disabled = false;
_focusMatch(0);
} else {
countEl.textContent = 'No matches';
}
}
toggleBtn.addEventListener('click', () => {
if (searchBar.classList.contains('open')) { close(); } else { open(); }
});
prevBtn.addEventListener('click', () => _focusMatch(focusedIdx - 1));
nextBtn.addEventListener('click', () => _focusMatch(focusedIdx + 1));
input.addEventListener('input', _runSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { close(); return; }
if (e.key === 'Enter') {
e.preventDefault();
_focusMatch(e.shiftKey ? focusedIdx - 1 : focusedIdx + 1);
}
});
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
if (searchBar.classList.contains('open')) { input.focus(); input.select(); } else { open(); }
}
});
}
/** Wires click handlers to the folder and project copy-link buttons. */
function initCopyBtn() {
const btn = el('presCopyBtn');
if (!btn) return;
btn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(window.location.href);
const orig = btn.innerHTML;
btn.innerHTML = '<span class="pres-copy-icon">✓</span> Copied!';
btn.disabled = true;
setTimeout(() => { btn.innerHTML = orig; btn.disabled = false; }, 2000);
} catch {}
});
const projBtn = el('presCopyProjectBtn');
if (!projBtn) return;
projBtn.addEventListener('click', async () => {
const url = `${window.location.origin}/presentation/${projects[currentIdx].projectId}`;
try {
await navigator.clipboard.writeText(url);
const orig = projBtn.innerHTML;
projBtn.innerHTML = '<span class="pres-copy-icon">✓</span> Copied!';
projBtn.disabled = true;
setTimeout(() => { projBtn.innerHTML = orig; projBtn.disabled = false; }, 2000);
} catch {}
});
}
/** Entry point: builds all panels and loads the first project in the playlist. */
async function init() {
const pdata = window.PDATA;
initCopyBtn();
initPresThemeToggle(null, null);
if (pdata.ownerName) {
el('presBreadcrumbOwner').textContent = pdata.ownerName;
el('presBreadcrumbOwner').style.display = '';
el('presBreadcrumbOwnerSep').style.display = '';
}
if (!pdata?.projects?.length) {
showState('presEmpty');
return;
}
// Build all Project instances up front
projects = pdata.projects.map(p => buildProject(p));
if (!projects.length) {
showState('presEmpty');
return;
}
// ── Build panels ──────────────────────────────────────────────────────────
ctrl = new PresentationController();
playerGroup = el('presPlayerGroup');
const wfMount = el('presWaveformMount');
presWaveform = new PresentationWaveform(wfMount, ctrl, {
onRegionHover: idx => ctrl.onRegionHover(idx),
onRegionSelect: idx => ctrl.onRegionSelect(idx),
onRegionActivate: idx => ctrl.onRegionActivate(idx),
onUserSeek: idx => ctrl.onUserSeek(idx),
});
const transcriptEl = el('presTranscript');
presTranscript = new PresentationTranscript(transcriptEl, ctrl);
presTranscript.scrollToSegment = (idx) => {
if (idx < 0) return;
const segEl = transcriptEl.querySelector(`.pt-seg[data-idx="${idx}"]`);
if (!segEl) return;
segEl.style.scrollMarginTop = (HEADER_H + playerGroup.offsetHeight + 16) + 'px';
segEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
presSectionNav = new PresentationSectionNav(
el('presSectionNav'),
el('presSectionNavList'),
el('presSectionNavToggle'),
);
ctrl.setPanels(presWaveform, presTranscript, presSectionNav);
initSearch();
buildPlaylist();
el('presPlaylistToggle').addEventListener('click', () => {
el('presPlaylist').classList.contains('open') ? closePlaylist() : openPlaylist();
});
el('presPlaylistBackdrop').addEventListener('click', closePlaylist);
const actionsToggle = el('presActionsToggle');
const actionsMenu = el('presActionsMenu');
actionsToggle.addEventListener('click', (e) => {
e.stopPropagation();
actionsMenu.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!actionsMenu.contains(e.target) && e.target !== actionsToggle) {
actionsMenu.classList.remove('open');
}
});
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft' && currentIdx > 0) loadProject(currentIdx - 1);
if (e.key === 'ArrowRight' && currentIdx < projects.length - 1) loadProject(currentIdx + 1);
});
// ── Sticky observer ───────────────────────────────────────────────────────
const sentinel = el('presWaveformSentinel');
new IntersectionObserver(([entry]) => {
const stuck = !entry.isIntersecting && entry.boundingClientRect.top < 0;
playerGroup.classList.toggle('pres-waveform-stuck', stuck);
}).observe(sentinel);
const searchAnchor = el('presSearchAnchor');
const sectionNavEl = el('presSectionNav');
const headerEl = document.querySelector('.pres-header');
const playlistEl = el('presPlaylist');
/** Keeps playlist panel top/height aligned with the header height. */
function _updatePlaylistTop() {
const h = headerEl ? headerEl.offsetHeight : HEADER_H;
playlistEl.style.top = h + 'px';
playlistEl.style.height = `calc(100vh - ${h}px)`;
}
_updatePlaylistTop();
if (headerEl) new ResizeObserver(_updatePlaylistTop).observe(headerEl);
/** Recalculates sticky top offsets for the search bar and section nav. */
function _updateStickyTops() {
const headerH_actual = headerEl ? headerEl.offsetHeight : HEADER_H;
const playerBottom = headerH_actual + playerGroup.offsetHeight;
searchAnchor.style.top = (playerBottom + 8) + 'px';
sectionNavEl.style.top = (playerBottom + 20) + 'px';
}
_updateStickyTops();
new ResizeObserver(_updateStickyTops).observe(playerGroup);
presSectionNav.setScrollOffset(() => (headerEl ? headerEl.offsetHeight : HEADER_H) + playerGroup.offsetHeight + 16);
presSectionNav.setOnNavigate(t => ctrl.seekTo(t));
// ── Load first project ────────────────────────────────────────────────────
showPresentation();
loadProject(0);
}
(async () => {
try {
await init();
} catch (err) {
console.error('Folder presentation init failed:', err);
showState('presNoAccess');
}
})();