`;
// Courses have no cover art, so the card art is a colour block in the
// course's own hue, with the cat/dur/play/percent overlays on top.
function thumb(hue, { cat, dur, play, percent } = {}) {
return `
${q.attempts ? `${q.best_score}/${q.questions}` : ''}
`;
/* ---------- auth ---------- */
/* Tells the boot-error handler in index.php that a UI is on screen, so later
failures get logged instead of buried under a full-screen error panel. */
function markBooted() { window.__chBooted = true; }
function showAuth() {
if (cleanupView) { cleanupView(); cleanupView = null; }
shell.hidden = true;
authEl.hidden = false;
renderLogin();
markBooted();
}
function showShell() {
authEl.hidden = true;
shell.hidden = false;
markBooted();
}
function renderLogin(error = '') {
authEl.innerHTML = `
${iconFill('play', 15)}CourseHub
Your course library.
Sign in to pick up where you stopped.
${error ? `
${esc(error)}
` : ''}
`;
const form = document.getElementById('login-form');
let action = 'login';
form.querySelectorAll('button[data-action]').forEach((b) =>
b.addEventListener('click', () => {
action = b.dataset.action;
if (action === 'register' && form.reportValidity()) form.requestSubmit();
}));
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
try {
// The session (and its CSRF token) may have expired while the tab sat
// on this form — fetch a fresh token so the POST is never rejected.
try {
const me = await fetch('api/auth.php').then((r) => r.json());
state.csrf = me.csrf || state.csrf;
} catch (e) { /* the POST below will surface the real problem */ }
const data = await api('api/auth.php', {
json: { action, username: fd.get('username'), password: fd.get('password') },
});
state.csrf = data.csrf || state.csrf;
state.user = data.username;
showShell();
toast(action === 'register' ? 'Account created — welcome.' : `Welcome back, ${data.username}.`);
await loadMeta();
route();
} catch (err) {
document.getElementById('login-error').innerHTML = `
${esc(err.message)}
`;
}
});
}
/* ---------- home ---------- */
function todayLine() {
const d = new Date();
const weekday = d.toLocaleDateString(undefined, { weekday: 'long' });
const month = d.toLocaleDateString(undefined, { month: 'long' });
return `${weekday}, ${d.getDate()} ${month}`;
}
function heroBlock(resume) {
if (!resume) {
return `
Nothing in progress
Open a course and start a video — it will show up here with your place kept.
${pass
? 'Every question answered correctly — this quiz counts as complete.'
: `${graded.total - graded.score} question${graded.total - graded.score === 1 ? '' : 's'} to get right for a complete pass.`}
${data.questions.length} question${data.questions.length === 1 ? '' : 's'}.
Answer them all, then submit — nothing is graded until you do.
${data.progress.attempts ? `Best so far ${data.progress.best_score}/${data.progress.total}.` : ''}
${scoreBlock}
${questions}
${graded
? `${graded.score} of ${graded.total} correct`
: `${answered} of ${data.questions.length} answered`}
${data.progress.attempts ? resetBtn('quiz', data.quiz.id, data.quiz.name, 'Reset attempts') : ''}
`;
}
view.onclick = null;
draw();
// One delegated handler for the whole quiz view, re-bound on each draw.
const onClick = async (e) => {
const opt = e.target.closest('.q-option');
if (opt && !graded) {
const qi = +opt.dataset.qi, oi = +opt.dataset.oi;
const multi = data.questions[qi].multi;
if (multi) {
const at = answers[qi].indexOf(oi);
if (at === -1) answers[qi].push(oi); else answers[qi].splice(at, 1);
} else {
answers[qi] = [oi];
}
draw();
return;
}
if (e.target.closest('[data-quiz-submit]') && !graded) {
try {
graded = await api('api/quiz.php', { json: { quiz_id: id, answers } });
draw();
toast(graded.completed
? `Perfect score — ${graded.score}/${graded.total}.`
: `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error');
loadMeta();
} catch (err) {
toast(err.message, 'error');
}
return;
}
if (e.target.closest('[data-quiz-retry]')) {
graded = null;
answers.forEach((a, i) => { answers[i] = []; });
draw();
}
};
view.addEventListener('click', onClick);
cleanupView = () => view.removeEventListener('click', onClick);
}
/* ---------- settings ---------- */
async function renderSettings() {
spinner();
const data = await api('api/library.php?view=settings');
const toggles = [
{ key: 'autoplay', label: 'Autoplay next video',
sub: 'Starts the next lesson a moment after one ends.' },
{ key: 'list-default', label: 'Open the library in list view',
sub: 'Otherwise courses open as a grid of cards.' },
];
const isOn = (key) => key === 'list-default' ? pref('view') === 'list' : pref(key) !== false;
view.innerHTML = `
Settings
Library folder
Scanned for courses, videos and quizzes on demand.
Total running time${esc(fmtLong(data.totals.duration) || '—')}
A video counts as watched at${data.complete_threshold}%
Last scan${esc(relTime(data.last_scan))}
${data.root_exists ? '' : `
This folder is not readable right now — scanning will fail until it is back.
`}
Playback and display
Stored in this browser, for this device.
${toggles.map((t) => `
${esc(t.label)}
${esc(t.sub)}
`).join('')}
Reset progress
You have progress on ${data.progress_courses} of ${data.courses} course${data.courses === 1 ? '' : 's'}.
Resetting clears watched marks, resume positions and quiz scores. Video files are never touched.
${resetBtn('all', 0, '', 'Reset all progress')}
Or reset one course from its row in the library list.
Account
Signed in as ${esc(data.username || '')}.
${data.registration_open ? 'New accounts can be created from the sign-in page.'
: 'Registration is closed on this install.'}
`;
}
/* ---------- player + progress tracking ---------- */
function initPlayer(data) {
const el = document.getElementById('player');
const statusEl = document.getElementById('save-status');
const player = new Plyr(el, {
// Plyr fetches its icon sprite and blank video from its CDN unless told
// otherwise — everything ships locally now.
iconUrl: 'assets/vendor/plyr.svg',
blankVideo: 'assets/vendor/blank.mp4',
keyboard: { focused: true, global: true },
seekTime: 10,
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
captions: { active: false, update: true },
controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress',
'current-time', 'duration', 'mute', 'volume', 'captions',
'settings', 'pip', 'fullscreen'],
});
let resumed = data.resume_at <= 0;
el.addEventListener('loadedmetadata', () => {
if (!resumed && data.resume_at < el.duration - 2) el.currentTime = data.resume_at;
resumed = true;
});
let lastSaved = -1;
let saveTimer = null;
const payload = () => ({
video_id: data.video.id,
position: el.currentTime || 0,
duration: el.duration || 0,
});
const status = (html) => { if (statusEl) statusEl.innerHTML = html; };
async function save() {
const p = payload();
if (state.suppressSave) return; // a reset just wiped this row — don't recreate it
if (!p.duration || Math.abs(p.position - lastSaved) < 1) return;
lastSaved = p.position;
try {
const res = await api('api/progress.php', { json: p, keepalive: true });
status(res.completed
? `${icon('check', 14, 2)}Completed`
: `${icon('clock', 14)}Progress saved`);
} catch (e) { /* transient — the next heartbeat retries */ }
}
function saveBeacon() {
const p = payload();
if (state.suppressSave || !p.duration) return;
navigator.sendBeacon('api/progress.php',
new URLSearchParams(Object.entries({ ...p, csrf: state.csrf || '' })
.map(([k, v]) => [k, String(v)])));
}
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); });
player.on('pause', save);
player.on('seeked', save);
player.on('ended', () => {
save();
if (data.next_id && pref('autoplay') !== false) {
status(`${icon('next', 14, 1.7)}Playing next video…`);
setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
}
});
const onHide = () => { if (document.visibilityState === 'hidden') saveBeacon(); };
document.addEventListener('visibilitychange', onHide);
window.addEventListener('pagehide', saveBeacon);
return function cleanup() {
saveBeacon();
if (saveTimer) clearInterval(saveTimer);
document.removeEventListener('visibilitychange', onHide);
window.removeEventListener('pagehide', saveBeacon);
try { player.destroy(); } catch (e) { /* already gone */ }
};
}
/* ---------- reset ---------- */
const RESET_COPY = {
all: () => ['Reset all progress?',
'Every saved position, watched mark and quiz result on your account is deleted. '
+ 'Your video files stay where they are.'],
folder: (label) => [`Reset progress on “${label}”?`,
'Saved positions, watched marks and quiz results inside this folder, including its '
+ 'subfolders, are deleted. Your video files stay where they are.'],
video: (label) => [`Reset progress on “${label}”?`,
'The saved position and watched mark for this video are deleted.'],
quiz: (label) => [`Reset attempts on “${label}”?`,
'Your attempts, best score and completion mark for this quiz are deleted.'],
};
async function doReset(scope, id, label) {
const copy = RESET_COPY[scope];
if (!copy) return;
const [title, body] = copy(label);
if (!await confirmDialog({ title, body, confirmLabel: 'Reset' })) return;
// The player's teardown would otherwise re-save the position just wiped.
state.suppressSave = true;
try {
const res = await api('api/reset.php', { json: { scope, id } });
const n = res.reset.videos + res.reset.quizzes;
toast(n
? `Progress reset — ${res.reset.videos} video${res.reset.videos === 1 ? '' : 's'}, `
+ `${res.reset.quizzes} quiz${res.reset.quizzes === 1 ? '' : 'zes'}.`
: 'Nothing to reset — no progress was saved here.');
await loadMeta();
route();
} catch (err) {
state.suppressSave = false;
toast(err.message, 'error');
}
}
/* ---------- scan ---------- */
async function runScan() {
if (state.scanning) return;
state.scanning = true;
shell.classList.add('is-scanning');
document.getElementById('scan-label').textContent = 'Scanning…';
document.getElementById('scan-when').textContent = 'now';
document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = true; });
try {
const res = await api('api/scan.php', { method: 'POST' });
const s = res.stats;
toast(`Scan finished — ${s.folders} folders, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
await loadMeta();
route();
} catch (err) {
toast(err.message, 'error');
} finally {
state.scanning = false;
shell.classList.remove('is-scanning');
document.getElementById('scan-label').textContent = 'Scan library';
document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = false; });
renderChrome();
}
}
async function logout() {
try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ }
state.user = null;
state.meta = null;
location.hash = '#/';
showAuth();
}
/* ---------- router ---------- */
async function route() {
if (cleanupView) { cleanupView(); cleanupView = null; }
state.suppressSave = false; // the teardown above is the only thing it guarded
closeMenu();
if (!state.user) { showAuth(); return; }
showShell();
const hash = location.hash || '#/';
const searchMatch = hash.match(/^#\/search\/(.*)$/);
syncSearchInput(searchMatch ? decodeURIComponent(searchMatch[1]) : '');
renderChrome();
let m;
try {
if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]);
else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]);
else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]);
else if (searchMatch) await renderSearch(decodeURIComponent(searchMatch[1]));
else if ((m = hash.match(/^#\/library(?:\/(\w+))?$/))) await renderLibrary(m[1] || null);
else if (hash.startsWith('#/settings')) await renderSettings();
else await renderHome();
} catch (err) {
if (state.user) {
view.innerHTML = `