CourseHub: AJAX video course player (PHP + MySQL + Bootstrap + Plyr)

Scans nested course folders for mp4s, tracks per-user resume position
and per-folder progress. SPA frontend with hash routing over a JSON API;
range-request streaming via stream.php.
This commit is contained in:
Harwinder Sekhon
2026-07-11 10:25:35 -04:00
commit 3ae6c1ae2e
16 changed files with 1328 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
/* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = `
<div class="toast align-items-center text-bg-${kind} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${esc(message)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`;
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 = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
}
function progressBar(percent, cls = 'bg-success') {
return `<div class="progress progress-thin">
<div class="progress-bar ${cls}" style="width:${percent}%"></div>
</div>`;
}
function breadcrumbs(crumbs, tail = '') {
const items = crumbs.map((c, i) =>
(i === crumbs.length - 1 && !tail)
? `<li class="breadcrumb-item active">${esc(c.name)}</li>`
: `<li class="breadcrumb-item"><a href="#/folder/${c.id}">${esc(c.name)}</a></li>`
).join('');
return `<nav><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#/"><i class="bi bi-house"></i> Courses</a></li>
${items}${tail ? `<li class="breadcrumb-item active">${esc(tail)}</li>` : ''}
</ol></nav>`;
}
/* ---------- 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 = `
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="card shadow-sm">
<div class="card-body p-4">
<h1 class="h4 mb-3 text-center"><i class="bi bi-collection-play-fill text-primary"></i> CourseHub</h1>
<div id="login-error">${error ? `<div class="alert alert-danger py-2">${esc(error)}</div>` : ''}</div>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Username</label>
<input class="form-control" name="username" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input class="form-control" type="password" name="password" required>
</div>
<div class="d-grid gap-2">
<button class="btn btn-primary" data-action="login">Sign in</button>
<button class="btn btn-outline-secondary" data-action="register">Create account</button>
</div>
</form>
</div>
</div>
</div>
</div>`;
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 =
`<div class="alert alert-danger py-2">${esc(err.message)}</div>`;
}
});
}
/* ---------- views ---------- */
async function renderHome() {
spinner();
const data = await api('api/library.php?view=home');
const cont = data.continue.length ? `
<h2 class="h5 mb-3"><i class="bi bi-play-circle me-1"></i>Continue watching</h2>
<div class="row g-3 mb-4">
${data.continue.map((r) => {
const pct = r.duration ? Math.min(100, Math.round(100 * r.position / r.duration)) : 0;
return `
<div class="col-sm-6 col-lg-4">
<a href="#/watch/${r.id}" class="card card-hover h-100 text-decoration-none">
<div class="card-body py-3">
<div class="fw-semibold text-truncate">${esc(r.name)}</div>
<div class="small text-secondary text-truncate mb-2">${esc(r.folder_path)}</div>
${progressBar(pct, '')}
<div class="small text-secondary mt-1">${fmt(r.position)} / ${fmt(r.duration)}</div>
</div>
</a>
</div>`;
}).join('')}
</div>` : '';
const courses = data.courses.length ? data.courses.map((c) => `
<div class="col-sm-6 col-lg-4">
<a href="#/folder/${c.id}" class="card card-hover h-100 text-decoration-none">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-folder-fill text-warning fs-3 me-2"></i>
<div class="fw-semibold">${esc(c.name)}</div>
</div>
${progressBar(c.progress.percent)}
<div class="small text-secondary mt-1">
${c.progress.completed} / ${c.progress.total} videos completed · ${c.progress.percent}%
</div>
</div>
</a>
</div>`).join('')
: `<div class="col-12"><div class="alert alert-info">
No courses found yet. Add course folders to the videos directory and click
<strong>Rescan library</strong>.</div></div>`;
app.innerHTML = `${cont}
<h2 class="h5 mb-3"><i class="bi bi-mortarboard me-1"></i>Courses</h2>
<div class="row g-3">${courses}</div>`;
}
async function renderFolder(id) {
spinner();
const data = await api(`api/library.php?view=folder&id=${id}`);
const subfolders = data.subfolders.length ? `
<h2 class="h6 text-secondary text-uppercase">Sections</h2>
<div class="list-group mb-4">
${data.subfolders.map((f) => `
<a href="#/folder/${f.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
<i class="bi bi-folder-fill text-warning fs-4"></i>
<div class="flex-grow-1 min-w-0">
<div class="fw-semibold text-truncate">${esc(f.name)}</div>
<div class="mt-1">${progressBar(f.progress.percent)}</div>
</div>
<span class="badge text-bg-secondary">${f.progress.completed}/${f.progress.total}</span>
<i class="bi bi-chevron-right text-secondary"></i>
</a>`).join('')}
</div>` : '';
const videos = data.videos.length ? `
<h2 class="h6 text-secondary text-uppercase">Videos</h2>
<div class="list-group">
${data.videos.map((v) => {
const icon = v.completed
? '<i class="bi bi-check-circle-fill text-success fs-4"></i>'
: (v.position > 5
? '<i class="bi bi-play-circle-fill text-primary fs-4"></i>'
: '<i class="bi bi-circle text-secondary fs-4"></i>');
return `
<a href="#/watch/${v.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
${icon}
<div class="flex-grow-1 min-w-0">
<div class="text-truncate">${esc(v.name)}</div>
${!v.completed && v.percent > 0 ? `<div class="mt-1">${progressBar(v.percent, '')}</div>` : ''}
</div>
<span class="text-secondary small">${fmt(v.duration)}</span>
</a>`;
}).join('')}
</div>` : (!data.subfolders.length ? '<div class="alert alert-secondary">This folder is empty.</div>' : '');
app.innerHTML = `
${breadcrumbs(data.breadcrumbs)}
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<h1 class="h4 mb-0"><i class="bi bi-folder2-open text-warning me-2"></i>${esc(data.folder.name)}</h1>
<div class="text-secondary small">${data.progress.completed} / ${data.progress.total} videos completed</div>
</div>
<div class="mb-4">${progressBar(data.progress.percent)}</div>
${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
? `<i class="bi bi-check-circle-fill ${active ? '' : 'text-success'}"></i>`
: (active ? '<i class="bi bi-play-fill"></i>' : '<i class="bi bi-circle text-secondary"></i>');
return `
<a href="#/watch/${p.id}"
class="list-group-item list-group-item-action d-flex align-items-center gap-2 ${active ? 'active' : ''}">
${icon}
<span class="text-truncate flex-grow-1">${esc(p.name)}</span>
<span class="small opacity-75">${fmt(p.duration)}</span>
</a>`;
}).join('');
app.innerHTML = `
${breadcrumbs(data.breadcrumbs, data.video.name)}
<div class="row g-4">
<div class="col-lg-8">
<div class="ratio ratio-16x9 bg-black rounded overflow-hidden mb-3">
<video id="player" playsinline controls>
<source src="${data.stream_url}" type="video/mp4">
</video>
</div>
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 class="h5 mb-0">${esc(data.video.name)}</h1>
<div class="btn-group">
${data.prev_id ? `<a class="btn btn-sm btn-outline-secondary" href="#/watch/${data.prev_id}">
<i class="bi bi-skip-backward-fill me-1"></i>Previous</a>` : ''}
${data.next_id ? `<a class="btn btn-sm btn-outline-secondary" href="#/watch/${data.next_id}">
Next<i class="bi bi-skip-forward-fill ms-1"></i></a>` : ''}
</div>
</div>
${data.resume_at > 0
? `<div class="text-secondary small mt-2">
<i class="bi bi-clock-history me-1"></i>Resuming from ${fmt(data.resume_at)}</div>` : ''}
<div id="save-status" class="text-secondary small mt-1"></div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header fw-semibold"><i class="bi bi-list-ol me-1"></i>${esc(data.folder.name)}</div>
<div class="list-group list-group-flush playlist-scroll">${playlist}</div>
</div>
</div>
</div>`;
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
? '<i class="bi bi-check-circle-fill text-success me-1"></i>Completed'
: '<i class="bi bi-cloud-check me-1"></i>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 = `<div class="alert alert-danger">${esc(err.message)}</div>
<a class="btn btn-primary" href="#/">Back to courses</a>`;
}
}
}
/* ---------- 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 = '<span class="spinner-border spinner-border-sm me-1"></span>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 = '<i class="bi bi-arrow-repeat me-1"></i>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();
})();
})();
+24
View File
@@ -0,0 +1,24 @@
.card-hover {
transition: transform 0.12s ease, border-color 0.12s ease;
}
.card-hover:hover {
transform: translateY(-2px);
border-color: var(--bs-primary);
}
.progress-thin {
height: 6px;
}
.playlist-scroll {
max-height: 60vh;
overflow-y: auto;
}
.min-w-0 {
min-width: 0;
}
:root {
--plyr-color-main: var(--bs-primary);
}