`;
// 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 `
`;
}
// A category is a folder of courses. It gets the same card as a course so the
// library reads as one grid, with the course count where a course shows its
// videos, and no play affordance — there is nothing to play at this level.
function categoryCard(c) {
const hue = hueOf(c.id);
return `
${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })}
${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;
// The advisor dock is a sibling of #app-shell, so hiding the shell does not
// hide it. Clearing the transcript here is also what stops one person's
// conversation surviving into the next sign-in in the same tab.
advisorReset({ wipe: true });
syncAdvisor();
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');
document.getElementById('f-user').focus(); // the autofocus attribute is inert on injected markup
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.
`;
}
/* ---------- course advisor ---------- */
// The transcript lives in this tab: it survives navigating around the app and
// dies with the tab. Deliberately not 'coursehub.advisor' — that string is the
// localStorage show/hide flag behind pref('advisor'), and the two used to sit
// one store apart under the same name.
const ADVISOR_KEY = 'coursehub.advisor.chat';
// Shown only when the server has it switched on and this browser has not hidden it.
const advisorOn = () => !!(state.meta && state.meta.advisor) && pref('advisor') !== false;
// The player and a quiz are the two screens you are *inside* something, so the
// bubble stays out of them. It also keeps the panel's textarea clear of Plyr's
// window-level key handler (initPlayer, keyboard.global) — belt and braces,
// since Plyr already ignores keys typed into a textarea.
const isFocusRoute = () => /^#\/(?:watch|quiz)\/\d+$/.test(location.hash || '#/');
// Built once, bound once, never torn down: the dock outlives every route, so
// unlike a view nothing here may assume it is rebuilt on navigation.
let advisorEl = null;
let advisorTurns = [];
let advisorOpen = false;
let advisorBusy = false;
let advisorLastFocus = null;
function advisorLoad() {
try {
const t = JSON.parse(sessionStorage.getItem(ADVISOR_KEY) || '[]');
return Array.isArray(t) ? t : [];
} catch (e) {
return [];
}
}
function advisorSave() {
try { sessionStorage.setItem(ADVISOR_KEY, JSON.stringify(advisorTurns)); } catch (e) { /* not persisted */ }
}
/**
* One line of the model's text, with "#412" turned into a link to the course
* the server resolved it to. The split runs over the *raw* line, never the
* escaped one: esc() writes an apostrophe as "'", which this pattern would
* otherwise match as a course id and cut the entity in half.
*/
function advisorLine(raw, courses) {
const out = [];
const re = /#(\d{1,9})\b/g;
let last = 0;
let m;
while ((m = re.exec(raw)) !== null) {
const c = courses && courses[m[1]];
if (!c) continue; // an id the server could not place stays plain text
out.push(esc(raw.slice(last, m.index)));
out.push(`${esc(c.name)}`);
last = m.index + m[0].length;
}
out.push(esc(raw.slice(last)));
return out.join('');
}
/** The model's plain text as HTML: paragraphs, and "- " lines as bullets. Escaped first. */
function advisorAnswerHtml(text, courses) {
const out = [];
let para = [];
let list = [];
const line = (l) => advisorLine(l, courses);
const flushP = () => { if (para.length) { out.push(`
${para.map(line).join(' ')}
`); para = []; } };
const flushL = () => { if (list.length) { out.push(`
${list.map((l) => `
${line(l)}
`).join('')}
`); list = []; } };
for (const raw of String(text || '').split('\n')) {
const l = raw.trim();
const m = l.match(/^[-•*]\s+(.*)$/);
if (!l) { flushP(); flushL(); continue; }
if (m) { flushP(); list.push(m[1]); continue; }
flushL();
para.push(l);
}
flushP();
flushL();
return out.join('');
}
const safeHttpUrl = (u) => (/^https?:\/\//i.test(String(u || '')) ? String(u) : '');
function advisorTurnHtml(t) {
if (t.role === 'user') return `
`;
}
function advisorDraw(extra = '') {
if (!advisorEl) return;
advisorEl.root.classList.toggle('has-turns', advisorTurns.length > 0);
advisorEl.thread.innerHTML = advisorTurns.map(advisorTurnHtml).join('') + extra;
// The thread is its own scroll box. Pin it twice: the first run lands before
// the web font has settled the final line heights, which leaves it short.
const pin = () => { advisorEl.thread.scrollTop = advisorEl.thread.scrollHeight; };
pin();
requestAnimationFrame(pin);
}
function advisorSetBusy(b) {
advisorBusy = b;
if (!advisorEl) return;
advisorEl.input.disabled = b;
advisorEl.submit.disabled = b;
}
/** Announces to a screen reader. The thread itself cannot be a live region:
* advisorDraw() replaces it wholesale, so every repaint would re-read it all. */
function advisorSay(message) {
if (advisorEl) advisorEl.live.textContent = message;
}
function advisorMarkUnread() {
if (!advisorEl) return;
advisorEl.dot.hidden = false;
advisorEl.fab.setAttribute('aria-label', 'Course advisor — new answer');
}
/** wipe: the session ended, so drop the transcript rather than just the view of it. */
function advisorReset({ wipe = false } = {}) {
if (advisorBusy && !wipe) return;
advisorTurns = [];
if (wipe) { try { sessionStorage.removeItem(ADVISOR_KEY); } catch (e) { /* nothing to clear */ } }
else advisorSave();
if (!advisorEl) return;
advisorEl.note.textContent = '';
advisorEl.dot.hidden = true;
advisorEl.fab.setAttribute('aria-label', 'Course advisor');
advisorDraw();
}
// What the server replays to the model: the assistant's own JSON, so the
// follow-up sees exactly what it said before. The "#412" markers in `answer`
// must survive — they keep a follow-up coherent, and advisorRelevant() greps
// for them to pull those courses into the details block. `courses` is derived
// from the answer and deliberately not replayed.
const advisorMessages = (list) => list.map((t) => t.role === 'user'
? { role: 'user', content: t.text }
: { role: 'assistant', content: JSON.stringify({
answer: t.answer,
recommended: (t.recommended || []).map((r) => ({ folder_id: r.folder_id, order: r.order, reason: r.reason })),
from_ine: (t.from_ine || []).map((r) => ({ catalog_id: String(r.catalog_id).slice(0, 8), reason: r.reason })),
missing_prerequisites: t.missing || [],
}) });
async function advisorAsk(text) {
// No routeSeq guard: the panel outlives every route, so an answer that lands
// after the user navigated is a good answer, not a stale one.
advisorTurns.push({ role: 'user', text });
advisorSave();
advisorSetBusy(true);
advisorSay('Thinking…');
advisorDraw(`
Thinking…
`);
try {
const res = await api('api/advisor.php', { json: { messages: advisorMessages(advisorTurns) } });
advisorTurns.push({ role: 'assistant', answer: res.answer, recommended: res.recommended || [],
from_ine: res.from_ine || [], missing: res.missing_prerequisites || [],
courses: res.courses || {} });
advisorSave();
advisorDraw();
const left = res.usage && res.usage.remaining_today;
advisorEl.note.textContent = left === undefined ? '' : `${left} question${left === 1 ? '' : 's'} left today.`;
advisorSay('The advisor answered.');
if (!advisorOpen) advisorMarkUnread(); // it arrived behind a closed bubble
if (res.degraded) toast('The advisor answered in plain text this time, without course cards.', 'error');
} catch (err) {
// A 401 sent us to the sign-in screen, and showAuth() has already wiped the
// transcript — advisorTurns is a fresh array now and must not be popped.
if (!state.user) return;
advisorTurns.pop(); // the question goes back in the box
advisorSave();
if (err.status === 503) { // switched off on the server since the page loaded
if (state.meta) state.meta.advisor = false;
closeAdvisor({ restoreFocus: false });
syncAdvisor();
toast('The course advisor is switched off.', 'error');
return;
}
advisorEl.input.value = text;
advisorDraw(`
${esc(err.message)}
`);
} finally {
advisorSetBusy(false);
}
}
const advisorIsSheet = () => matchMedia('(max-width: 900px)').matches;
function openAdvisor() {
if (!advisorEl || advisorOpen) return;
advisorOpen = true;
advisorLastFocus = document.activeElement;
advisorEl.root.classList.add('is-open');
advisorEl.panel.hidden = false;
advisorEl.scrim.hidden = false;
advisorEl.fab.setAttribute('aria-expanded', 'true');
advisorEl.dot.hidden = true;
advisorEl.fab.setAttribute('aria-label', 'Course advisor');
document.body.classList.toggle('advisor-sheet-open', advisorIsSheet());
advisorDraw();
advisorEl.input.focus();
}
function closeAdvisor({ restoreFocus = true } = {}) {
if (!advisorEl || !advisorOpen) return;
advisorOpen = false;
advisorEl.root.classList.remove('is-open');
advisorEl.panel.hidden = true;
advisorEl.scrim.hidden = true;
advisorEl.fab.setAttribute('aria-expanded', 'false');
document.body.classList.remove('advisor-sheet-open');
if (!restoreFocus) return; // the route changed; the old focus is gone with it
const back = advisorLastFocus && advisorLastFocus.isConnected ? advisorLastFocus : advisorEl.fab;
back.focus();
}
/** Builds the dock on first use, so a browser with the advisor hidden never
* gets the markup or the listeners. Idempotent. */
function renderAdvisor() {
if (advisorEl) return;
const root = document.createElement('div');
root.className = 'advisor-dock';
root.hidden = true;
// The bubble comes first so Tab runs bubble → panel; CSS stacks the panel
// above it with column-reverse.
root.innerHTML = `
Course advisor
Ask what to take next, or in what order — answered from your own library.
`;
document.getElementById('overlay-root').appendChild(root);
const form = root.querySelector('.advisor-form');
advisorEl = {
root,
fab: root.querySelector('.advisor-fab'),
dot: root.querySelector('.advisor-dot'),
scrim: root.querySelector('.advisor-scrim'),
panel: root.querySelector('.advisor-panel'),
thread: root.querySelector('.advisor-thread'),
input: form.querySelector('textarea'),
submit: form.querySelector('button[type=submit]'),
note: root.querySelector('.advisor-note'),
live: root.querySelector('.sr-only'),
};
advisorTurns = advisorLoad();
advisorDraw();
advisorEl.fab.addEventListener('click', () => (advisorOpen ? closeAdvisor() : openAdvisor()));
root.addEventListener('click', (e) => {
if (e.target.closest('[data-advisor-close]')) closeAdvisor();
});
root.querySelector('[data-advisor-new]').addEventListener('click', () => {
advisorReset();
advisorEl.input.focus();
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = advisorEl.input.value.trim();
if (!text || advisorBusy) return;
advisorEl.input.value = '';
advisorAsk(text);
});
advisorEl.input.addEventListener('keydown', (e) => { // Enter asks, Shift+Enter breaks the line
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
});
advisorEl.thread.addEventListener('click', (e) => {
// The desktop panel sits beside the app, so a course link just navigates
// underneath it. The mobile sheet covers the page it navigated to.
if (e.target.closest('a[href^="#/"]') && advisorIsSheet()) closeAdvisor({ restoreFocus: false });
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !advisorOpen) return;
if (document.querySelector('.overlay, .menu')) return; // a dialog or the account menu owns this one
closeAdvisor();
});
}
/** The one path that decides whether the bubble is on screen. Called from
* renderChrome(), so every route change and every loadMeta() covers it. */
function syncAdvisor() {
const want = !!state.user && advisorOn() && !isFocusRoute();
document.body.classList.toggle('has-advisor', want); // shifts the mobile toast strip clear of it
if (!want) {
if (advisorEl) { closeAdvisor({ restoreFocus: false }); advisorEl.root.hidden = true; }
return;
}
renderAdvisor();
advisorEl.root.hidden = false;
}
/* ---------- library ---------- */
/**
* Split courses onto the three progress shelves. The API sends one flat list
* and this partitions it — shipping the partition as well would put every
* course in the payload twice.
*/
const shelveCourses = (courses) =>
[['progress', 'In progress'], ['finished', 'Finished'], ['fresh', 'Not started']]
.map(([key, name]) => ({ key, name, courses: courses.filter((c) => c.state === key) }))
.filter((s) => s.courses.length);
/**
* A shelf block: a heading, a count and the cards on it. Used by the library
* and by a category page, which shelves its own courses the same way.
*/
const shelfBlock = (shelf, mode) => `
`;
async function renderLibrary(filter) {
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
spinner();
const data = await load('api/library.php?view=library');
// A shelf link from the sidebar is always a flat list of matching courses;
// otherwise the saved layout decides.
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
const mode = pref('view');
const active = filter || 'all';
const shown = active === 'all' ? data.courses : data.courses.filter((c) => c.state === active);
const chips = ['all', 'progress', 'finished', 'fresh'].map((k) => `
${esc(SHELF_NAME[k])}
`).join('');
let body;
if (!data.courses.length) {
body = `
The library is empty
Add course folders to the videos directory, then scan.
`;
} else if (layout === 'categories' && data.categories.length) {
// The folders above the courses are the library's own shape — a few dozen
// cards to browse instead of a wall of every course at once.
body = categoryList(data.categories, mode)
+ (data.top_courses.length ? `
Courses
${data.top_courses.length}
${courseList(data.top_courses, mode)}` : '');
} else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) {
body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('');
} else {
body = `
${chips}
` + (shown.length
? courseList(shown, mode)
: `
Nothing on this shelf
No course is ${esc(SHELF_NAME[active].toLowerCase())} right now.
`;
}
async function renderFolder(id) {
spinner();
const data = await load(`api/library.php?view=folder&id=${id}`);
const mode = pref('view');
const hue = hueOf(data.course_id);
const isCategory = data.kind === 'category';
// A category holds courses (and possibly more categories), so it reads like
// a small library of its own; a course holds sections, which are just a
// list. Both come back on the same route — only the shape below differs.
const sections = isCategory
? (data.categories.length ? `
${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') : ''}
`;
}
draw();
// Exhibits are external images; when one can't load, say so instead of
// leaving a blank box (the caption's link still opens the original).
const onImageError = (e) => {
const fig = e.target.closest && e.target.closest('.q-exhibit');
if (fig) fig.classList.add('is-broken');
};
view.addEventListener('error', onImageError, true); // error events don't bubble
let submitting = false;
// 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;
}
const submit = e.target.closest('[data-quiz-submit]');
if (submit && !graded) {
if (submitting) return; // a second click while the first is in flight
submitting = true;
submit.disabled = true;
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) {
submit.disabled = false;
toast(err.message, 'error');
} finally {
submitting = false;
}
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);
view.removeEventListener('error', onImageError, true);
};
}
/* ---------- settings ---------- */
async function renderSettings() {
spinner();
const data = await load('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.
Course details${data.course_meta_counts.sidecar} from files ·
${data.course_meta_counts.catalog} from the catalogue ·
${data.course_meta_counts.none} without
` : ''}
${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('')}
Course advisor
${data.advisor && data.advisor.enabled ? `
On — answers come from ${esc(data.advisor.model || 'the configured model')}.
You have used ${data.advisor.used_today} of ${data.advisor.limit} question${data.advisor.limit === 1 ? '' : 's'} today.
Show the advisor button
The bubble in the bottom-right corner, on every screen but the player
and a quiz. This browser only; the server setting stays on.
`
: `
Off. Set AI_ENABLED and AI_API_KEY in config.php to turn it on — the app works the same without it,
and makes no outbound requests while it is off.
`}
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;
});
// A play button elsewhere in the app (the home hero) asked for the video to
// start, not just for the player to open. Autoplay is only permitted off the
// gesture that got us here, so the flag is consumed once and never survives
// a reload — and it waits for the resume seek above, so play() picks up at
// the saved position rather than at zero.
if (state.playOnLoad === data.video.id) {
state.playOnLoad = null;
const start = () => { const p = el.play(); if (p) p.catch(() => { /* autoplay refused — controls are there */ }); };
if (el.readyState >= 1) start();
else el.addEventListener('loadedmetadata', start, { once: 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(force = false) {
const p = payload();
if (state.suppressSave) return; // a reset just wiped this row — don't recreate it
if (!p.duration || (!force && 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)])));
}
let nextTimer = null;
player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(() => save(), 5000); });
player.on('pause', () => save());
player.on('seeked', () => save());
player.on('ended', () => {
save(true); // the final position always lands, even within 1 s of the last heartbeat
if (data.next_id && pref('autoplay') !== false) {
status(`${icon('next', 14, 1.7)}Playing next video…`);
// "Autoplay next" has to actually start it — navigating alone just
// opened the next video paused. The gesture that began this playback
// still counts, so the browser lets the next one start.
state.playOnLoad = data.next_id;
nextTimer = 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();
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
// Autoplay-next arms playOnLoad before it navigates, and route() tears the
// old view down after the hash has already changed — so only disarm when
// we are leaving for somewhere other than the video it queued.
if (state.playOnLoad === data.next_id && location.hash !== `#/watch/${data.next_id}`) {
state.playOnLoad = null;
}
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.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed`
+ (s.meta_none === undefined ? '.' : `; details for ${s.courses - s.meta_none} of ${s.courses} courses.`));
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() {
routeSeq++; // anything still loading for the previous view is now stale
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\/(.*)$/);
const query = searchMatch ? safeDecode(searchMatch[1]) : '';
syncSearchInput(query);
renderChrome();
// Views replace each other in the same document, so the page keeps the old
// scroll offset unless told otherwise — except while a search is being refined.
if (!(searchMatch && document.activeElement === searchInput)) window.scrollTo(0, 0);
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(query);
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 (err instanceof StaleRender) return; // a newer route is drawing; nothing to show
if (state.user) {
view.innerHTML = `