871 lines
29 KiB
JavaScript
871 lines
29 KiB
JavaScript
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);
|
||
}
|
||
|
||
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) => sound.stop());
|
||
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();
|
||
}
|
||
|
||
toggleAudio() {
|
||
if (!this.audioElement) return;
|
||
this.audioElement.playing() ? this.pauseAudio() : this.playAudio();
|
||
}
|
||
|
||
toggleMute() {
|
||
this.isMuted = !this.isMuted;
|
||
Howler.mute(this.isMuted);
|
||
this.updateIcon();
|
||
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.updateIcon();
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
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 WRAP_COURSE_CONTENT = document.getElementById("wrap-course-content");
|
||
WRAP_COURSE_CONTENT.setAttribute("data-original-class", WRAP_COURSE_CONTENT.className);
|
||
|
||
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");
|
||
|
||
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 {
|
||
// SCORM API no encontrada. Usando sessionStorage para web.
|
||
}
|
||
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,
|
||
audio: m.audio,
|
||
visited: false,
|
||
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",
|
||
cancelButton: "btn btn-secondary",
|
||
},
|
||
}).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.textContent = item.title;
|
||
|
||
const idx = courseData.contentArray.findIndex((c) => c.content === item.content && c.title === item.title);
|
||
link.dataset.coursenavindex = idx;
|
||
link.dataset.coursenavvisited = true;
|
||
wdiv.appendChild(link);
|
||
|
||
link.addEventListener("click", () => {
|
||
CLICK_SOUND.play();
|
||
const index = parseInt(link.dataset.coursenavindex, 10);
|
||
if (index >= 0) {
|
||
// Permitir navegación libre desde el menú
|
||
currentIndex = index;
|
||
closeSidebar();
|
||
loadContent();
|
||
} 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();
|
||
}
|
||
|
||
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",
|
||
cancelButton: "btn btn-warning",
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ==========================================================================
|
||
* === 7. Carga de contenido y actualización de la interfaz ================
|
||
* ========================================================================== */
|
||
function loadContent() {
|
||
MAIN_CONTENT.innerHTML = "";
|
||
WRAP_COURSE_CONTENT.className = WRAP_COURSE_CONTENT.getAttribute("data-original-class");
|
||
window.scrollTo(0, 0);
|
||
LOADER_ELEMENT.style.display = "block";
|
||
|
||
const item = courseData.contentArray[currentIndex];
|
||
if (!item?.content) return console.warn("Ítem inválido:", item);
|
||
|
||
audioController.stopAudio();
|
||
Howler._howls?.forEach((h) => h.stop());
|
||
|
||
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"));
|
||
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;
|
||
//executeInjectedScripts(MAIN_CONTENT);
|
||
$(MAIN_CONTENT).html(html);
|
||
})
|
||
.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 = true;
|
||
link.classList.add("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);
|
||
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);
|
||
}
|
||
|
||
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]);
|
||
}
|
||
|
||
function markSlidesAsVisited(indices) {
|
||
indices
|
||
.sort((a, b) => b - a)
|
||
.forEach((i) => {
|
||
currentIndex = i;
|
||
setSlideVisited(true);
|
||
});
|
||
}
|
||
|
||
function resetCourse() {
|
||
courseData.contentArray.forEach((i) => (i.visited = false));
|
||
courseData.maximumAdvance = 0;
|
||
currentIndex = 0;
|
||
updateUITemplate();
|
||
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 (0–100), … }
|
||
*/
|
||
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));
|
||
|
||
// Offcanvas backdrop dentro de custom container
|
||
const offcanvasEl = document.getElementById("coursenav-offcanvas");
|
||
const customContainer = document.getElementById("wrap-course-content");
|
||
const bsOffcanvas = bootstrap.Offcanvas.getOrCreateInstance(offcanvasEl);
|
||
|
||
offcanvasEl.addEventListener("shown.bs.offcanvas", () => {
|
||
if (customContainer.querySelector(".offcanvas-backdrop")) return;
|
||
const backdrop = document.createElement("div");
|
||
backdrop.className = "offcanvas-backdrop fade";
|
||
customContainer.appendChild(backdrop);
|
||
backdrop.getBoundingClientRect();
|
||
backdrop.classList.add("show");
|
||
backdrop.addEventListener("click", () => bsOffcanvas.hide());
|
||
});
|
||
|
||
offcanvasEl.addEventListener("hidden.bs.offcanvas", () => {
|
||
customContainer.querySelector(".offcanvas-backdrop")?.remove();
|
||
});
|
||
});
|
||
|
||
/* ==========================================================================
|
||
* === 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,
|
||
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;
|