`;
// A course thumbnail is a real captured frame when the scanner made one
// (see generateThumbnails() server-side); until then, or if the request
// 404s, onerror drops the broken and the colour block underneath
// (already painted — cat/dur/play/percent all sit above it) shows through.
function thumb(hue, { cat, dur, play, percent, courseId } = {}) {
return `
${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') : ''}
`;
reveal();
}
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();
markGraded();
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);
/** Correct cards settle into place; wrong ones shake their heads. */
function markGraded() {
if (!canAnimate()) return;
graded.results.forEach((r, qi) => {
const card = view.querySelector(`[data-qi="${qi}"].q-card`);
if (!card) return;
if (r.correct) {
anime({ targets: card, scale: [0.98, 1], duration: 420, delay: qi * 45,
easing: 'easeOutBack', complete: () => clearInline([card]) });
} else {
anime({ targets: card, duration: 440, delay: qi * 45, easing: 'easeInOutSine',
keyframes: [{ translateX: -7 }, { translateX: 6 }, { translateX: -4 }, { translateX: 0 }],
complete: () => clearInline([card]) });
}
});
}
}
/* ---------- 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: 'motion', label: 'Animate transitions',
sub: 'Fades views in and grows progress bars. Off is always honoured when your system asks for reduced motion.' },
{ 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.'}
`;
reveal();
}
/* ---------- player + progress tracking ---------- */
function initPlayer(data) {
const el = document.getElementById('player');
const statusEl = document.getElementById('save-status');
const player = new Plyr(el, {
keyboard: { focused: true, global: true },
seekTime: 10,
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] },
controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress',
'current-time', 'duration', 'mute', 'volume', '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).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 = `