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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+426
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
||||
})();
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user