/* CourseHub SPA — hash router + fetch API + Plyr player. * Routes: #/ home (courses, continue watching) * #/folder/N browse a folder * #/watch/N video player */ (function () { 'use strict'; const app = document.getElementById('app'); const state = { user: null }; let cleanupView = null; // teardown hook for the active view (player etc.) /* ---------- helpers ---------- */ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function fmt(seconds) { if (!seconds || seconds <= 0) return ''; const s = Math.round(seconds); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`; } function toast(message, kind = 'success') { const wrap = document.createElement('div'); wrap.innerHTML = ` `; const el = wrap.firstElementChild; document.getElementById('toasts').appendChild(el); const t = new bootstrap.Toast(el, { delay: 4000 }); el.addEventListener('hidden.bs.toast', () => el.remove()); t.show(); } async function api(url, options = {}) { if (options.json) { options.method = options.method || 'POST'; options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify(options.json); delete options.json; } const res = await fetch(url, options); const data = await res.json().catch(() => ({})); if (res.status === 401) { state.user = null; updateNav(); renderLogin(); throw new Error(data.error || 'Not logged in'); } if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`); return data; } function spinner() { app.innerHTML = '
'; } function progressBar(percent, cls = 'bg-success') { return `
`; } function breadcrumbs(crumbs, tail = '') { const items = crumbs.map((c, i) => (i === crumbs.length - 1 && !tail) ? `` : `` ).join(''); return ``; } /* ---------- nav / auth ---------- */ function updateNav() { const navUser = document.getElementById('nav-user'); navUser.classList.toggle('d-none', !state.user); document.getElementById('nav-username').textContent = state.user || ''; } function renderLogin(error = '') { if (cleanupView) { cleanupView(); cleanupView = null; } app.innerHTML = `

CourseHub

${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; })); form.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(form); try { const data = await api('api/auth.php', { json: { action, username: fd.get('username'), password: fd.get('password') }, }); state.user = data.username; updateNav(); toast(action === 'register' ? 'Account created — welcome!' : `Welcome back, ${data.username}!`); route(); } catch (err) { document.getElementById('login-error').innerHTML = `
${esc(err.message)}
`; } }); } /* ---------- views ---------- */ async function renderHome() { spinner(); const data = await api('api/library.php?view=home'); const cont = data.continue.length ? `

Continue watching

${data.continue.map((r) => { const pct = r.duration ? Math.min(100, Math.round(100 * r.position / r.duration)) : 0; return `
${esc(r.name)}
${esc(r.folder_path)}
${progressBar(pct, '')}
${fmt(r.position)} / ${fmt(r.duration)}
`; }).join('')}
` : ''; const courses = data.courses.length ? data.courses.map((c) => `
${esc(c.name)}
${progressBar(c.progress.percent)}
${c.progress.completed} / ${c.progress.total} videos completed · ${c.progress.percent}%
`).join('') : `
No courses found yet. Add course folders to the videos directory and click Rescan library.
`; app.innerHTML = `${cont}

Courses

${courses}
`; } async function renderFolder(id) { spinner(); const data = await api(`api/library.php?view=folder&id=${id}`); const subfolders = data.subfolders.length ? `

Sections

${data.subfolders.map((f) => `
${esc(f.name)}
${progressBar(f.progress.percent)}
${f.progress.completed}/${f.progress.total}
`).join('')}
` : ''; const videos = data.videos.length ? `

Videos

${data.videos.map((v) => { const icon = v.completed ? '' : (v.position > 5 ? '' : ''); return ` ${icon}
${esc(v.name)}
${!v.completed && v.percent > 0 ? `
${progressBar(v.percent, '')}
` : ''}
${fmt(v.duration)}
`; }).join('')}
` : (!data.subfolders.length ? '
This folder is empty.
' : ''); app.innerHTML = ` ${breadcrumbs(data.breadcrumbs)}

${esc(data.folder.name)}

${data.progress.completed} / ${data.progress.total} videos completed
${progressBar(data.progress.percent)}
${subfolders}${videos}`; } async function renderWatch(id) { spinner(); const data = await api(`api/library.php?view=video&id=${id}`); const playlist = data.playlist.map((p) => { const active = p.id === data.video.id; const icon = p.completed ? `` : (active ? '' : ''); return ` ${icon} ${esc(p.name)} ${fmt(p.duration)} `; }).join(''); app.innerHTML = ` ${breadcrumbs(data.breadcrumbs, data.video.name)}

${esc(data.video.name)}

${data.prev_id ? ` Previous` : ''} ${data.next_id ? ` Next` : ''}
${data.resume_at > 0 ? `
Resuming from ${fmt(data.resume_at)}
` : ''}
${esc(data.folder.name)}
${playlist}
`; cleanupView = initPlayer(data); } /* ---------- 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, }); async function save() { const p = payload(); 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 }); if (statusEl) { statusEl.innerHTML = res.completed ? 'Completed' : 'Progress saved'; } } catch (e) { /* transient — next heartbeat retries */ } } function saveBeacon() { const p = payload(); if (!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) { if (statusEl) statusEl.textContent = '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() { // called when navigating away saveBeacon(); if (saveTimer) clearInterval(saveTimer); document.removeEventListener('visibilitychange', onHide); window.removeEventListener('pagehide', saveBeacon); try { player.destroy(); } catch (e) { /* already gone */ } }; } /* ---------- router ---------- */ async function route() { if (cleanupView) { cleanupView(); cleanupView = null; } if (!state.user) { renderLogin(); return; } const hash = location.hash || '#/'; 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 await renderHome(); } catch (err) { if (state.user) { app.innerHTML = `
${esc(err.message)}
Back to courses`; } } } /* ---------- global buttons ---------- */ document.getElementById('btn-logout').addEventListener('click', async () => { try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ } state.user = null; updateNav(); location.hash = '#/'; renderLogin(); }); document.getElementById('btn-rescan').addEventListener('click', async (e) => { const btn = e.currentTarget; btn.disabled = true; btn.innerHTML = 'Scanning…'; try { const res = await api('api/scan.php', { method: 'POST' }); toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos indexed.`); route(); // refresh current view with new data } catch (err) { toast(err.message, 'danger'); } finally { btn.disabled = false; btn.innerHTML = 'Rescan library'; } }); /* ---------- boot ---------- */ window.addEventListener('hashchange', route); (async function boot() { try { const me = await fetch('api/auth.php').then((r) => r.json()); state.user = me.logged_in ? me.username : null; } catch (e) { state.user = null; } updateNav(); route(); })(); })();