942 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

var CourseNav = (function (COURSE_CONFIG) {
"use strict";
/* ==========================================================================
* === 1. Inyección y ejecución de scripts de contenido dinámico ============
* ========================================================================== */
const loadedScriptSrcs = new Set();
function executeInjectedScripts(container) {
// 1) Scripts externos (src)
container.querySelectorAll("script[src]").forEach((old) => {
const src = old.src;
if (!loadedScriptSrcs.has(src)) {
loadedScriptSrcs.add(src);
const s = document.createElement("script");
s.src = src;
s.async = false;
document.body.appendChild(s);
s.addEventListener("load", () => s.remove());
}
old.remove();
});
// 2) Scripts inline
container.querySelectorAll("script:not([src])").forEach((old) => {
try {
(0, eval)(old.textContent);
} catch (e) {
console.error("Error al ejecutar script inline:", e);
}
old.remove();
});
}
/* ==========================================================================
* === 2. Extensión de Howl para eventos de tiempo ===========================
* ========================================================================== */
class ExtendedHowl extends Howl {
constructor(options) {
super(options);
this._timeupdateListeners = [];
this._interval = null;
this._startTimeUpdate();
}
_startTimeUpdate() {
this._interval = setInterval(() => {
if (this.playing()) this._emitTimeUpdate(this.seek());
}, 250);
}
_emitTimeUpdate(currentTime) {
this._timeupdateListeners.forEach((cb) => cb(currentTime));
}
onTimeUpdate(cb) {
this._timeupdateListeners.push(cb);
}
offTimeUpdate(cb) {
this._timeupdateListeners = this._timeupdateListeners.filter((l) => l !== cb);
}
off(event, fn, id) {
// Cuando se llama a off() sin argumentos, limpia todos los listeners.
// Lo extendemos para que también limpie nuestros listeners de timeupdate.
if (arguments.length === 0) {
this._timeupdateListeners = [];
}
// Llama al método off() del padre
return super.off(event, fn, id);
}
play(id) {
const result = super.play(id);
if (!this._interval) this._startTimeUpdate();
return result;
}
pause(id) {
const result = super.pause(id);
clearInterval(this._interval);
this._interval = null;
return result;
}
stop(id) {
super.stop(id);
clearInterval(this._interval);
this._interval = null;
}
}
function createSound(audioUrl) {
return audioUrl ? new ExtendedHowl({ src: [audioUrl] }) : null;
}
/* ==========================================================================
* === 3. Controlador de audio ===============================================
* ========================================================================== */
class AudioController {
constructor() {
this.audioElement = null;
this.audioControlButton = document.getElementById("coursenav-audio-control");
this.audioIcon = document.getElementById("coursenav-audio-icon");
this.progressCircle = document.getElementById("coursenav-progress-circle");
this.isMuted = false;
if (this.progressCircle) this.progressCircle.style.display = "none";
if (this.audioControlButton) {
this.audioControlButton.addEventListener("click", this.toggleAudio.bind(this));
}
}
stopAllSoundsAndPlay(howl) {
Howler._howls?.forEach((sound) => {
// Detener y remover eventos de todos los sonidos EXCEPTO el que se va a reproducir.
if (sound !== howl) {
sound.stop();
//sound.off();
}
});
this.updateIcon();
this.setAudio(howl);
this.playAudio();
}
loadAudio(url) {
if (this.audioElement) this.audioElement.stop();
if (!url) return;
this.audioElement = createSound(url);
this._bindAudioEvents();
}
playAudio() {
this.audioElement?.play();
}
pauseAudio() {
this.audioElement?.pause();
}
stopAudio() {
this.audioElement?.stop();
if (this.progressCircle) this.progressCircle.style.display = "none";
this.updateIconVolume();
this.audioElement = null;
}
toggleAudio() {
if (!this.audioElement) return;
this.audioElement.playing() ? this.toggleMute() : this.playAudio();
}
toggleMute() {
this.isMuted = !this.isMuted;
Howler.mute(this.isMuted);
this.updateIconVolume();
document.querySelectorAll("video").forEach((v) => (v.muted = this.isMuted));
}
onPlay() {
if (this.progressCircle) this.progressCircle.style.display = "block";
const t = this.audioElement.seek();
this.updateProgressCircle(t);
this.updateIconVolume();
}
onEnd() {
if (this.progressCircle) this.progressCircle.style.display = "none";
this.updateIcon();
}
updateIcon() {
if (!this.audioIcon) return;
const playing = this.audioElement?.playing();
this.audioIcon.className = playing ? "fa-duotone fa-solid fa-pause" : "fa-duotone fa-solid fa-play";
}
updateIconVolume() {
if (!this.audioIcon) return;
this.audioIcon.className = this.isMuted ? "fa-duotone fa-solid fa-volume-slash" : "fa-duotone fa-solid fa-volume";
}
updateProgressCircle(currentTime) {
if (!this.progressCircle || !this.audioElement) return;
const r = parseFloat(this.progressCircle.getAttribute("r"));
const circ = 2 * Math.PI * r;
const offset = circ - (currentTime / this.audioElement.duration()) * circ;
this.progressCircle.setAttribute("stroke-dashoffset", offset);
}
setAudioUrl(url) {
this.loadAudio(url);
}
setAudio(howl) {
if (!(howl instanceof ExtendedHowl)) return;
this.audioElement?.stop();
this.audioElement = howl;
this._bindAudioEvents();
}
_bindAudioEvents() {
this.audioElement.on("play", this.onPlay.bind(this));
this.audioElement.on("pause", this.updateIcon.bind(this));
this.audioElement.on("stop", this.updateIcon.bind(this));
this.audioElement.on("end", this.onEnd.bind(this));
this.audioElement.onTimeUpdate(this.updateProgressCircle.bind(this));
}
}
const audioController = new AudioController();
/* ==========================================================================
* === 4. Configuración global y constantes =================================
* ========================================================================== */
const COURSE_CONFIG_URL = COURSE_CONFIG.COURSE_CONFIG_URL || "config.json";
const DEBUG = COURSE_CONFIG.DEBUG || false;
const KEY_APP = COURSE_CONFIG.KEY || Infinity;
const MAIN_CONTENT = document.getElementById("coursenav-main-content");
const LOADER_ELEMENT = document.getElementById("coursenav-loader-course");
const CLICK_SOUND = new Audio("audio/click.mp3");
const PREV_BTN = document.getElementById("coursenav-prev-btn");
const NEXT_BTN = document.getElementById("coursenav-next-btn");
const COURSE_PROGRESS_BAR = document.getElementById("coursenav-progress-bar");
const COURSE_MENU = document.getElementById("coursenav-main-menu");
const BODY_CLASSES = [...document.body.classList];
pipwerks.SCORM.version = "1.2";
pipwerks.debug.isActive = DEBUG;
pipwerks.SCORM.handleExitMode = false;
let sessionStartTime;
let scormAPIUnloaded = false;
let courseStructure = null;
let courseData = { contentArray: [], maximumAdvance: 0 };
let currentIndex = 0;
/* ==========================================================================
* === 5. SCORM: Inicialización y helpers de alto nivel =====================
* ========================================================================== */
function initializeScorm(callback) {
const scorm = pipwerks.SCORM;
const connected = scorm.init();
if (connected) {
const status = scorm.get("cmi.core.lesson_status");
if (status === "not attempted") {
scorm.set("cmi.core.lesson_status", "incomplete");
scorm.save();
}
sessionStartTime = Date.now();
} else {
console.warn("SCORM API no encontrada. Usando sessionStorage.");
}
callback(connected);
}
function buildContentArray(mods, courseTitle = "", moduleTitle = "", parentTitle = null) {
mods.forEach((m) => {
const isModuleLevel = !parentTitle && !moduleTitle;
const currentModuleTitle = isModuleLevel ? m.title : moduleTitle;
if (m.content) {
courseData.contentArray.push({
title: m.title,
content: m.content,
visited: false,
showAnimation: true,
courseTitle,
moduleTitle: currentModuleTitle,
parentTitle,
});
}
if (m.topics) {
buildContentArray(m.topics, courseTitle, currentModuleTitle, m.title);
}
});
}
function verifyContentArray(saved, curr) {
if (!saved || !curr || saved.length !== curr.length) return false;
return saved.every((s, i) => s.title === curr[i].title && s.content === curr[i].content);
}
function loadConfig() {
const xhr = new XMLHttpRequest();
xhr.open("GET", `${COURSE_CONFIG_URL}?_=${Date.now()}`, true);
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
xhr.withCredentials = true;
xhr.responseType = "json";
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
courseStructure = xhr.response;
courseData = { contentArray: [], maximumAdvance: 0 };
if (courseStructure.title) document.title = courseStructure.title;
buildContentArray(courseStructure.modules, courseStructure.title || "");
const saved = getProgress();
if (saved.contentArray && verifyContentArray(saved.contentArray, courseData.contentArray)) {
courseData = saved;
}
if (courseData.maximumAdvance > 0) showStartOptions();
else initializeCourse();
setProgress(courseData);
} else {
MAIN_CONTENT?.remove();
console.error("Error cargando config:", xhr.status, xhr.statusText);
}
};
xhr.onerror = function () {
MAIN_CONTENT?.remove();
console.error("Error de red al cargar config");
};
xhr.send();
}
function initializeCourse() {
if (COURSE_MENU) {
buildMenu();
hideDuplicateLinks();
}
if (courseData.contentArray.length > 0) loadContent();
else MAIN_CONTENT.innerHTML = `<div class='alert alert-warning'>No hay contenido.</div>`;
setupNavigation();
}
function showStartOptions() {
if (typeof Swal === "undefined") {
currentIndex = confirm("¿Retomar tu progreso?") ? courseData.maximumAdvance : 0;
initializeCourse();
} else {
Swal.fire({
title: "¿Dónde quieres empezar?",
text: "Retomar o comenzar de nuevo",
icon: "question",
showCancelButton: true,
confirmButtonText: "Retomar",
cancelButtonText: "Comenzar",
target: MAIN_CONTENT,
customClass: {
confirmButton: "btn btn-primary text-white",
cancelButton: "btn btn-secondary text-white",
},
}).then((res) => {
currentIndex = res.isConfirmed ? courseData.maximumAdvance : 0;
initializeCourse();
});
}
}
/* ==========================================================================
* === 6. Construcción y manejo del menú ====================================
* ========================================================================== */
function buildMenu() {
COURSE_MENU.innerHTML = "";
(courseStructure.modules || []).forEach((module) => {
const ul = document.createElement("ul");
ul.classList.add("course-menu");
ul.appendChild(createMenuItem(module));
COURSE_MENU.appendChild(ul);
});
hideDuplicateLinks();
}
function createMenuItem(item) {
const li = document.createElement("li");
li.classList.add("menu-item");
const wdiv = document.createElement("div");
wdiv.classList.add("witem");
li.appendChild(wdiv);
const link = document.createElement("a");
link.classList.add("coursenav-link");
link.innerHTML = item.title;
const idx = courseData.contentArray.findIndex((c) => c.content === item.content && c.title === item.title);
link.dataset.coursenavindex = idx;
link.dataset.coursenavvisited = idx >= 0 && courseData.contentArray[idx].visited;
wdiv.appendChild(link);
link.addEventListener("click", () => {
CLICK_SOUND.play();
const index = parseInt(link.dataset.coursenavindex, 10);
if (index >= 0) {
if (DEBUG || courseData.contentArray[index].visited) {
currentIndex = index;
closeSidebar();
loadContent();
} else {
closeSidebar();
showLockedContentWarning();
}
} else {
const toggle = wdiv.querySelector(".toggle-icon");
toggle && toggle.click();
}
});
if (item.topics?.length) {
const toggle = document.createElement("span");
toggle.classList.add("toggle-icon");
toggle.innerHTML = '<i class="fa-duotone fa-solid fa-square-chevron-down"></i>';
wdiv.appendChild(toggle);
const subUl = document.createElement("ul");
subUl.classList.add("sub-ul", "open");
item.topics.forEach((sub) => subUl.appendChild(createMenuItem(sub)));
li.appendChild(subUl);
toggle.addEventListener("click", () => {
CLICK_SOUND.play();
const isOpen = subUl.classList.toggle("open");
const icon = toggle.querySelector("i");
icon.classList.toggle("fa-square-chevron-down", isOpen);
icon.classList.toggle("fa-square-chevron-right", !isOpen);
});
}
return li;
}
function hideDuplicateLinks() {
function processUl(ul) {
const seen = new Set();
Array.from(ul.children)
.filter((el) => el.tagName === "LI")
.forEach((li) => {
const link = li.querySelector(":scope > .witem > .coursenav-link");
if (link) {
const text = link.textContent.trim();
if (seen.has(text)) li.style.display = "none";
else seen.add(text);
}
li.querySelectorAll(":scope > ul").forEach(processUl);
});
}
document.querySelectorAll("#coursenav-main-menu > ul.course-menu").forEach(processUl);
}
function closeSidebar() {
const offEl = document.getElementById("coursenav-offcanvas");
const bsOff = bootstrap.Offcanvas.getInstance(offEl) || new bootstrap.Offcanvas(offEl);
bsOff.hide();
}
/**
* Abre el offcanvas de navegación (o cualquier sidebar) usando la API de Bootstrap.
*/
function openSidebar() {
const offEl = document.getElementById("coursenav-offcanvas");
if (offEl) {
// Obtén la instancia de Bootstrap Offcanvas o créala si no existe
const bsOff = bootstrap.Offcanvas.getInstance(offEl) || new bootstrap.Offcanvas(offEl);
bsOff.show();
}
}
function showLockedContentWarning() {
if (typeof Swal === "undefined") {
alert("Debes completar el contenido actual antes de avanzar.");
} else {
Swal.fire({
text: "Debes completar el contenido actual antes de avanzar.",
icon: "warning",
target: MAIN_CONTENT,
customClass: {
confirmButton: "btn btn-primary text-white",
cancelButton: "btn btn-warning text-white",
},
});
}
}
/* ==========================================================================
* === 7. Carga de contenido y actualización de la interfaz ================
* ========================================================================== */
function loadContent() {
document.body.className = ""; // Limpiar primero
BODY_CLASSES.forEach((clase) => document.body.classList.add(clase));
MAIN_CONTENT.innerHTML = "";
window.scrollTo(0, 0);
LOADER_ELEMENT.style.display = "block";
if (NEXT_BTN) NEXT_BTN.classList.remove('look_at_me');
const item = courseData.contentArray[currentIndex];
if (!item?.content) return console.warn("Ítem inválido:", item);
audioController.stopAudio();
audioController.audioElement?.off();
Howler._howls?.forEach((h) => h.stop());
// Detener todas las animaciones de GSAP para evitar que se ejecuten en segundo plano
if (window.gsap) {
// gsap.killAll() es para miembros del Club GreenSock.
// Si no está disponible, usamos un método alternativo para la versión gratuita.
if (typeof gsap.killAll === "function") {
gsap.killAll();
} else {
gsap.killTweensOf(gsap.utils.toArray("*"));
}
}
if (Swal.isVisible()) {
Swal.close();
}
// Al cerrar, limpiamos clases y atributos de html/body
document.documentElement.classList.remove("swal2-shown", "swal2-height-auto");
document.body.classList.remove("swal2-shown", "swal2-height-auto");
document.documentElement.removeAttribute("aria-hidden");
document.body.removeAttribute("aria-hidden");
// Y volvemos a quitar aria-hidden de los scripts
document.querySelectorAll("script[aria-hidden]").forEach((el) => el.removeAttribute("aria-hidden"));
document.querySelectorAll("[aria-hidden]").forEach((el) => el.removeAttribute("aria-hidden"));
fetch(item.content, { cache: "no-store" })
.then((r) => {
if (!r.ok) throw new Error(r.statusText);
return r.text();
})
.then((html) => {
//MAIN_CONTENT.innerHTML = html;
$(MAIN_CONTENT).html(html);
//executeInjectedScripts(MAIN_CONTENT);
})
.catch((err) => {
console.error("Error cargando contenido:", err);
MAIN_CONTENT.innerHTML = `<pre>${err.message}</pre>`;
})
.finally(() => {
courseData.maximumAdvance = Math.max(courseData.maximumAdvance, currentIndex);
LOADER_ELEMENT.style.display = "none";
updateUITemplate();
triggerSlideChange(currentIndex, courseData.contentArray);
});
}
function updateUITemplate() {
setProgress(courseData);
updateNavigationButtons();
updateProgressBar();
updateCourseNavLinks();
}
function updateCourseNavLinks() {
document.querySelectorAll(".coursenav-link").forEach((link) => {
const idx = parseInt(link.dataset.coursenavindex, 10);
const item = courseData.contentArray[idx];
if (item) {
link.dataset.coursenavvisited = item.visited;
item.visited ? link.classList.add("visited") : link.classList.remove("visited");
}
});
}
function setupNavigation() {
PREV_BTN?.addEventListener("click", () => {
//CLICK_SOUND.play();
navigate(-1);
});
NEXT_BTN?.addEventListener("click", () => {
//CLICK_SOUND.play();
navigate(1);
});
updateNavigationButtons();
}
function navigate(dir) {
triggerBeforeSlideChange(currentIndex, courseData.contentArray);
if (NEXT_BTN) NEXT_BTN.classList.remove('look_at_me');
const newIndex = currentIndex + dir;
if (newIndex < 0 || newIndex >= courseData.contentArray.length) return;
if (dir === -1 || courseData.contentArray[currentIndex].visited || DEBUG) {
currentIndex = newIndex;
loadContent();
} else {
showLockedContentWarning();
}
updateNavigationButtons();
}
function updateNavigationButtons() {
if (!courseData.contentArray.length || !courseData.contentArray[currentIndex]) {
PREV_BTN.disabled = NEXT_BTN.disabled = true;
return;
}
PREV_BTN.disabled = currentIndex === 0;
NEXT_BTN.disabled = currentIndex >= courseData.contentArray.length - 1 || (!courseData.contentArray[currentIndex].visited && !DEBUG);
if (NEXT_BTN) {
const isLastSlide = currentIndex >= courseData.contentArray.length - 1;
const currentSlide = courseData.contentArray[currentIndex];
// Mostrar animación solo si showAnimation es true y no es el último slide
if (!isLastSlide && currentSlide.showAnimation && currentSlide.visited) {
NEXT_BTN.classList.add('look_at_me');
currentSlide.showAnimation = false; // Marcar para no mostrar nuevamente
setProgress(courseData); // Guardar el estado
} else {
NEXT_BTN.classList.remove('look_at_me');
}
}
}
function updateProgressBar() {
const visited = courseData.contentArray.filter((i) => i.visited).length;
const pct = (visited / courseData.contentArray.length) * 100;
COURSE_PROGRESS_BAR.style.width = pct + "%";
COURSE_PROGRESS_BAR.setAttribute("aria-valuenow", pct.toFixed(2));
COURSE_PROGRESS_BAR.textContent = `${pct.toFixed(0)}%`;
}
/* ==========================================================================
* === 8. Eventos custom =====================================================
* ========================================================================== */
function triggerSlideChange(currentIndex, contentArray) {
document.body.dispatchEvent(
new CustomEvent("slideChange", {
detail: { message: "Slide changed!", slideIndex: currentIndex, contentArray },
})
);
}
function triggerSlideCompleted(index, total, slideObj) {
document.body.dispatchEvent(
new CustomEvent("slideCompleted", {
detail: { message: "Slide completed!", slideIndex: index, totalSlides: total, slide: slideObj },
})
);
}
function triggerBeforeSlideChange(currentIndex, contentArray) {
document.body.dispatchEvent(
new CustomEvent("beforeSlideChange", {
detail: { message: "Before slide change!", currentIndex, contentArray },
})
);
}
/* ==========================================================================
* === 9. API públicas y utilitarios =========================================
* ========================================================================== */
function setSlideVisited(state = true) {
courseData.contentArray[currentIndex].visited = state;
updateUITemplate();
triggerSlideCompleted(currentIndex, courseData.contentArray.length, courseData.contentArray[currentIndex]);
// Agregar o remover la clase look_at_me al botón next
const nextBtn = document.getElementById('coursenav-next-btn');
}
function markSlidesAsVisited(indices) {
indices
.sort((a, b) => b - a)
.forEach((i) => {
currentIndex = i;
setSlideVisited(true);
});
}
function resetCourse() {
// 1. Resetear el estado local
courseData.contentArray.forEach(i => {
i.visited = false;
i.showAnimation = true;
});
courseData.maximumAdvance = 0;
currentIndex = 0;
// 2. Borrar todo el estado persistente
if (pipwerks.SCORM.connection.isActive) {
pipwerks.SCORM.set("cmi.core.lesson_status", "incomplete");
pipwerks.SCORM.set("cmi.core.lesson_location", "0"); // <- Esto es lo clave
pipwerks.SCORM.set("cmi.core.score.raw", "");
pipwerks.SCORM.set("cmi.suspend_data", "");
pipwerks.SCORM.save();
} else {
sessionStorage.removeItem("cmi.core.lesson_status");
sessionStorage.setItem("cmi.core.lesson_location", "0"); // <- Esto es lo clave
sessionStorage.removeItem("cmi.core.score.raw");
sessionStorage.removeItem("cmi.suspend_data");
}
// 3. Forzar recarga limpia
setProgress(courseData);
loadContent();
}
function soundClick() {
CLICK_SOUND.play();
}
function isDebug() {
return DEBUG;
}
function gotoSlide(index) {
const i = Math.floor(index);
if (!isNaN(i) && i >= 0 && i < courseData.contentArray.length) {
currentIndex = i;
loadContent();
} else {
console.error("gotoSlide: índice inválido", index);
}
}
/* ==========================================================================
* === 10. SCORM: helpers de bajo nivel y progreso ============================
* ========================================================================== */
function getLessonLocation() {
if (pipwerks.SCORM.connection.isActive) {
const val = pipwerks.SCORM.get("cmi.core.lesson_location");
return val ?? "";
}
return sessionStorage.getItem("cmi.core.lesson_location") ?? "";
}
function setLessonLocation(loc) {
if (pipwerks.SCORM.connection.isActive) {
const ok = pipwerks.SCORM.set("cmi.core.lesson_location", loc);
if (ok) pipwerks.SCORM.save();
return ok;
}
sessionStorage.setItem("cmi.core.lesson_location", loc);
return true;
}
function getLessonStatus() {
if (pipwerks.SCORM.connection.isActive) {
return pipwerks.SCORM.get("cmi.core.lesson_status") ?? "";
}
return sessionStorage.getItem("cmi.core.lesson_status") ?? "";
}
function setLessonStatus(st) {
if (pipwerks.SCORM.connection.isActive) {
const ok = pipwerks.SCORM.set("cmi.core.lesson_status", st);
if (ok) pipwerks.SCORM.save();
return ok;
}
sessionStorage.setItem("cmi.core.lesson_status", st);
return true;
}
function getScore() {
let val = pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.get("cmi.core.score.raw") : sessionStorage.getItem("cmi.core.score.raw");
return val != null && val !== "" ? Number(val) : null;
}
function setScore(sc) {
if (pipwerks.SCORM.connection.isActive) {
const ok = pipwerks.SCORM.set("cmi.core.score.raw", sc);
if (ok) pipwerks.SCORM.save();
return ok;
}
sessionStorage.setItem("cmi.core.score.raw", sc);
return true;
}
function getSuspendData() {
let val = pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.get("cmi.suspend_data") : sessionStorage.getItem("cmi.suspend_data");
if (val) {
try {
return JSON.parse(val);
} catch {
return val;
}
}
return "";
}
function setSuspendData(data) {
const json = JSON.stringify(data);
if (pipwerks.SCORM.connection.isActive) {
const ok = pipwerks.SCORM.set("cmi.suspend_data", json);
if (ok) pipwerks.SCORM.save();
return ok;
}
sessionStorage.setItem("cmi.suspend_data", json);
return true;
}
function getProgressPercent(byModule = false) {
if (!byModule) {
const visited = courseData.contentArray.filter((i) => i.visited).length;
return parseFloat(((visited / courseData.contentArray.length) * 100).toFixed(2));
}
const currentSlide = courseData.contentArray[currentIndex];
const moduleSlides = courseData.contentArray.filter((s) => s.moduleTitle === currentSlide.moduleTitle);
const visited = moduleSlides.filter((i) => i.visited).length;
return moduleSlides.length ? parseFloat(((visited / moduleSlides.length) * 100).toFixed(2)) : 0;
}
/**
* Obtiene el porcentaje de avance de cada módulo.
* @returns {Object<string, number>} Un objeto con
* { "Título de módulo": porcentaje (0100), … }
*/
function getProgressByModule() {
// Recolectamos totales y visitados por módulo
const stats = {};
courseData.contentArray.forEach((slide) => {
const mod = slide.moduleTitle || "Sin módulo";
if (!stats[mod]) stats[mod] = { total: 0, visited: 0 };
stats[mod].total++;
if (slide.visited) stats[mod].visited++;
});
// Calculamos porcentajes
const result = {};
Object.entries(stats).forEach(([mod, { total, visited }]) => {
result[mod] = parseFloat(((visited / total) * 100).toFixed(2));
});
return result;
}
function setProgress(p) {
setSuspendData(p);
}
function getProgress() {
return getSuspendData() || { contentArray: [], maximumAdvance: 0 };
}
function finishScorm() {
if (pipwerks.SCORM.connection.isActive && !scormAPIUnloaded) {
const elapsed = (Date.now() - sessionStartTime) / 1000;
const hh = String(Math.floor(elapsed / 3600)).padStart(2, "0");
const mm = String(Math.floor((elapsed % 3600) / 60)).padStart(2, "0");
const ss = String(Math.floor(elapsed % 60)).padStart(2, "0");
pipwerks.SCORM.set("cmi.core.session_time", `${hh}:${mm}:${ss}`);
pipwerks.SCORM.save();
pipwerks.SCORM.quit();
scormAPIUnloaded = true;
}
}
function getScormData(key) {
return pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.get(key) ?? "" : sessionStorage.getItem(key) ?? "";
}
function setScormData(key, value) {
if (pipwerks.SCORM.connection.isActive) {
const ok = pipwerks.SCORM.set(key, value);
if (ok) pipwerks.SCORM.save();
return ok;
}
sessionStorage.setItem(key, value);
return true;
}
/* ==========================================================================
* === 11. Arranque DOM y offcanvas =========================================
* ========================================================================== */
document.addEventListener("DOMContentLoaded", () => {
initializeScorm(() => loadConfig());
window.addEventListener("beforeunload", finishScorm);
// Tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map((el) => new bootstrap.Tooltip(el));
});
/* ==========================================================================
* === 12. API pública =======================================================
* ========================================================================== */
return {
/* Audio */
audioController,
createSound,
soundClick,
/* Debug */
isDebug,
/* SCORM Básico */
getStudentName: () => getScormData("cmi.core.student_name"),
getLessonLocation,
setLessonLocation,
getLessonStatus,
setLessonStatus,
getScore,
setScore,
getSuspendData,
setSuspendData,
getScormData,
setScormData,
/* Navegación */
nextSlide: () => navigate(1),
prevSlide: () => navigate(-1),
gotoSlide,
openSidebar,
closeSidebar,
isVisited: () => courseData.contentArray[currentIndex]?.visited || false,
isCompletedSlideIndex: (idx) => (idx >= 0 && idx < courseData.contentArray.length ? courseData.contentArray[idx].visited : undefined),
/* Estado del curso */
getCurrentSlide: () => courseData.contentArray[currentIndex],
getCurrentIndex: () => currentIndex,
getCourseData: () => courseData,
getCourseStructure: () => courseStructure,
getCourseConfig: () => COURSE_CONFIG,
getCourseTitle: () => courseStructure?.title || "",
getCourseModules: () => courseStructure?.modules || [],
getCourseContentArray: () => courseData.contentArray,
/* Curso actual */
resetCourse,
markSlidesAsVisited,
setSlideVisited,
completeLesson: () => setLessonStatus("completed"),
updateProgressBar,
getProgressPercent,
getProgressByModule,
getCurrentModuleSlides: () => {
const module = courseData.contentArray[currentIndex]?.moduleTitle;
return courseData.contentArray.filter((s) => s.moduleTitle === module);
},
getCurrentModuleTitle: () => courseData.contentArray[currentIndex]?.moduleTitle || "",
getCurrentCourseTitle: () => courseData.contentArray[currentIndex]?.courseTitle || "",
/* SCORM avanzado */
save: () => (pipwerks.SCORM.connection.isActive ? pipwerks.SCORM.save() : setProgress(courseData)),
reload: loadContent,
loadModule: (moduleTitle) => {
const idx = courseData.contentArray.findIndex((s) => s.moduleTitle === moduleTitle);
if (idx >= 0) {
currentIndex = idx;
loadContent();
}
},
};
})(COURSE_CONFIG);
window.CourseNav = CourseNav;