UI improved

This commit is contained in:
Harwinder Sekhon
2026-07-27 13:58:47 -04:00
parent 45e7465f7b
commit c4c88e52da
11 changed files with 3257 additions and 412 deletions
+141 -7
View File
@@ -1,13 +1,27 @@
# CourseHub
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB + Bootstrap 5 + [Plyr](https://github.com/sampotts/plyr).
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design
system, [Plyr](https://github.com/sampotts/plyr) and
[anime.js](https://animejs.com/). No CSS framework.
Single-page app: `index.php` is a static shell; all navigation, login, browsing,
playback, and scanning happen over AJAX against the JSON API in `api/`
(`auth.php`, `library.php`, `progress.php`, `scan.php`), with hash-based routes
(`#/`, `#/folder/N`, `#/watch/N`) so views switch without page reloads and
browser back/forward still work. Only `stream.php` (the mp4 bytes) is a plain
HTTP resource.
Single-page app: `index.php` is a static shell (sidebar, search header, mobile
tab bar); all navigation, login, browsing, playback, scanning and settings
happen over AJAX against the JSON API in `api/` (`auth.php`, `library.php`,
`progress.php`, `scan.php`, `quiz.php`, `reset.php`), with hash-based routes so
views switch without page reloads and browser back/forward still work:
| Route | Screen |
| --- | --- |
| `#/` | home — resume card, stats, in progress, last scan |
| `#/library`, `#/library/<shelf>` | every course, as shelves or a filtered catalog |
| `#/folder/N` | a course or section: sections + content |
| `#/watch/N` | player, playlist, section quizzes |
| `#/quiz/N` | take and grade a quiz |
| `#/search/Q` | courses, videos and quizzes matching `Q` |
| `#/settings` | library folder, preferences, reset |
Only `stream.php` (the mp4 bytes) and `thumb.php` (cached course thumbnails)
are plain HTTP resources.
- Recursively scans a videos directory for `.mp4` files and mirrors the folder
hierarchy (courses → sections → videos, any nesting depth).
@@ -16,6 +30,15 @@ HTTP resource.
percentage bar computed over its whole subtree.
- "Continue watching" strip, auto-play next video, playback speed, keyboard
shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`).
- **Search** courses, sections, videos and quizzes from the top bar.
- **Reset progress** for one video, one quiz, a whole course/section, or the
entire account.
- Course and section listings switch between **grid and list** layout, and the
library between **shelves and catalog**; both choices are remembered.
- **Shelves** group courses by how far you are: in progress, finished, not
started. The app has no course categories, so progress is what sorts them.
- **Course thumbnails** — a real frame captured from each course's first
video, 40% of the way through it.
- Seekable streaming via HTTP Range requests; files are addressed by DB id only
(no path traversal).
@@ -51,6 +74,15 @@ Top-level folders become courses on the home page.
the web server / PHP user).
- DB credentials (change the password in production).
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
- `THUMBNAILS_DIR`: where generated course thumbnails are cached — must be
*writable* by the web server / PHP user (default: `cache/thumbnails`
next to `config.php`).
- `THUMBNAIL_SEEK_PERCENT`: how far into a course's first video to capture
its thumbnail (default `0.4`, i.e. 40%).
An `ffmpeg` binary on `$PATH` is required for thumbnails (and is the
fallback for video duration if the pure-PHP mp4 parser can't read a file).
Without it, courses just keep their colour-block placeholder.
3. **Run** — with the PHP built-in server for testing:
@@ -96,6 +128,108 @@ correct set. Best score, last score, and attempt count are stored per user;
a quiz is *completed* once every question is answered correctly in one
attempt, and quiz scores count toward folder progress alongside videos.
## Thumbnails
Each course's card is a real frame, not just a colour block: the scanner finds
the course's first video — in the same order you'd browse to it (subfolders in
listing order, then that folder's videos) — and asks `ffmpeg` for a frame
`THUMBNAIL_SEEK_PERCENT` (default 40%) of the way through it, scaled and
cropped to 640×360. The result is cached at `THUMBNAILS_DIR/<course id>.jpg`
and served by `thumb.php?course_id=N`, which looks the file up by that ID only
(same containment check as `stream.php`) and sends normal `ETag`/
`Last-Modified` caching headers.
- Regeneration only happens when a course's first video has changed since the
last scan (tracked via `folders.thumbnail_video_id`), so rescans stay fast.
- If `ffmpeg` is missing, a video's duration can't be read, or generation
otherwise fails, the course simply keeps its colour-block placeholder — the
`<img>` 404s and the client removes it (`onerror="this.remove()"`), no error
is shown to the user. Failures are logged (`error_log`) and rolled up into
the scan's `thumbnails_failed` count.
- `thumbnail_video_id`/`thumbnail_generated_at` on `folders` are added
automatically on first use (`ensureThumbnailColumns()`) if a database
created before this feature doesn't have them yet — no manual migration
needed, the same self-healing `app_meta` uses.
## Search
The top-bar box searches as you type (from two characters, debounced) and also
on Enter. It matches folder, video and quiz **names** and their relative
**paths**, so `Course 1/Intro` lists everything under that section. Results are
grouped into courses/sections, videos and quizzes, with your progress shown on
each row. Searches live at `#/search/<query>`, so a result page can be
bookmarked or shared.
## Resetting progress
`api/reset.php` deletes progress rows for the logged-in user only — never for
anyone else, and never any video, quiz or folder itself. Four scopes:
| Where | Scope | Clears |
| --- | --- | --- |
| Player page → **Reset** | `video` | that video's position + completion |
| Quiz page → **Reset attempts** | `quiz` | that quiz's attempts, best/last score |
| Folder page → **Reset progress** | `folder` | everything in that folder's subtree |
| Library list row → ↺ | `folder` | that one course's subtree |
| Settings / avatar menu → **Reset all progress** | `all` | every video and quiz on the account |
Each one asks for confirmation first, and the reply reports how many video and
quiz rows were removed. Resetting the video you are watching also suppresses
the player's teardown save, so the position you just cleared isn't written back.
## Look and feel
The UI implements the **Course Library** design (a Claude Design document).
`assets/style.css` is the whole design system — tokens first, then layout, then
components. There is no CSS framework under it, and the icons are inline SVG
from the design rather than an icon font.
| Token | Hex | Used for |
| --- | --- | --- |
| Paper | `#EFEAE1` | outermost ground |
| Surface | `#FAF8F5` | app background |
| Sidebar | `#F5F1EA` | left rail |
| Card | `#FFFFFF` | cards, rows, dialogs |
| Ink | `#1A1814` → `#B8B0A2` | four text weights (`--ink` … `--ink-4`) |
| Accent | `#1F6F5C` | primary action, links, progress, completion |
| Danger | `#B84A3C` | reset and errors |
Type: **Inter** throughout — headings, interface text, and counts/durations/
paths (the design's own 'Elms Sans' ships only inside the design document).
Numeric text uses `tabular-nums` so columns of counts and durations still
line up without a separate monospace face.
Courses have no cover art, so each one gets a hue from the design's six-colour
set (clay, sage, slate, sand, plum, sky), picked from its folder id so it stays
the same across renders and reloads. That hue drives its thumbnail, its initial
badge and its progress bar.
anime.js drives the motion: views fade and stagger in, progress bars grow from
zero (so a reset is visible), correct quiz answers settle and wrong ones shake.
Everything is skipped when the CDN script is unavailable, when the visitor has
`prefers-reduced-motion: reduce` set, or when **Animate transitions** is turned
off in settings — the views then render in their final state.
Below 900 px the layout follows the design's mobile frame: the sidebar gives way
to a bottom tab bar, the search field becomes a pill with a round scan button
next to it, and grids drop to two columns.
## Settings
`#/settings` shows what the server actually knows and what this browser
remembers:
- **Library folder** — `VIDEOS_ROOT`, whether it is readable, indexed counts,
total running time, the watched threshold, and when the last scan ran.
- **Preferences**, stored per browser: autoplay next video, animate
transitions, open the library in list view.
- **Reset progress** — the account-wide reset, with a count of how many courses
currently hold progress.
Scan time lives in an `app_meta` key/value table. Reads tolerate the table not
existing, so an install created before it was added keeps working until its
next scan creates it.
## How progress works
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
+127 -8
View File
@@ -1,7 +1,11 @@
<?php
// GET api/library.php?view=home -> courses + continue-watching
// GET api/library.php?view=meta -> sidebar/header chrome: totals, shelves, last scan
// GET api/library.php?view=home -> resume card, stats, in progress, recent additions
// GET api/library.php?view=library -> every course, grouped into shelves
// GET api/library.php?view=folder&id=N -> folder contents + progress
// GET api/library.php?view=video&id=N -> video + playlist + resume info
// GET api/library.php?view=search&q=text -> matching folders, videos, quizzes
// GET api/library.php?view=settings -> library paths, thresholds, progress summary
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
@@ -53,12 +57,64 @@ function crumbList(array $folder): array
);
}
/** The three shelves the library groups courses into. */
function shelveCourses(array $courses): array
{
$shelves = [
['key' => 'progress', 'name' => 'In progress', 'courses' => []],
['key' => 'finished', 'name' => 'Finished', 'courses' => []],
['key' => 'fresh', 'name' => 'Not started', 'courses' => []],
];
foreach ($courses as $c) {
foreach ($shelves as $i => $shelf) {
if ($shelf['key'] === $c['state']) {
$shelves[$i]['courses'][] = $c;
}
}
}
return array_values(array_filter($shelves, fn($s) => $s['courses']));
}
$view = $_GET['view'] ?? 'home';
switch ($view) {
case 'home':
case 'meta':
$courses = courseCards($userId);
$counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0];
foreach ($courses as $c) {
$counts[$c['state']]++;
}
echo json_encode([
'courses' => array_map(fn($c) => folderSummary($c, $userId), childFolders(null)),
'totals' => libraryTotals(),
'shelves' => [
['key' => 'progress', 'name' => 'In progress', 'count' => $counts['progress']],
['key' => 'finished', 'name' => 'Finished', 'count' => $counts['finished']],
['key' => 'fresh', 'name' => 'Not started', 'count' => $counts['fresh']],
],
'last_scan' => appMetaGet('last_scan_at'),
'username' => currentUsername(),
]);
break;
case 'home':
$courses = courseCards($userId);
$resume = continueWatching($userId, 1);
$resumeCourseId = null;
if ($resume) {
$resumeFolder = getFolder((int)$resume[0]['folder_id']);
if ($resumeFolder) $resumeCourseId = courseIdOf($resumeFolder);
}
echo json_encode([
'stats' => userStats($userId, $courses),
'resume' => $resume ? [
'id' => (int)$resume[0]['id'],
'course_id' => $resumeCourseId,
'name' => $resume[0]['name'],
'folder_path' => $resume[0]['folder_path'],
'position' => (float)$resume[0]['position_seconds'],
'duration' => $resume[0]['duration_seconds'] !== null
? (float)$resume[0]['duration_seconds'] : null,
] : null,
'continue' => array_map(fn($r) => [
'id' => (int)$r['id'],
'name' => $r['name'],
@@ -66,6 +122,18 @@ switch ($view) {
'position' => (float)$r['position_seconds'],
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
], continueWatching($userId)),
'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')),
'recent' => recentCourses($userId),
'courses' => $courses,
]);
break;
case 'library':
$courses = courseCards($userId);
echo json_encode([
'courses' => $courses,
'shelves' => shelveCourses($courses),
'totals' => libraryTotals(),
]);
break;
@@ -76,11 +144,15 @@ switch ($view) {
echo json_encode(['error' => 'Folder not found']);
break;
}
$crumbs = crumbList($folder);
echo json_encode([
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
'breadcrumbs' => crumbList($folder),
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
'path' => $folder['path']],
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
'breadcrumbs' => $crumbs,
'rollup' => folderRollup($userId, $folder['path']),
'progress' => folderProgress($userId, $folder['path']),
'subfolders' => array_map(fn($f) => folderSummary($f, $userId),
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
childFolders((int)$folder['id'])),
'videos' => array_map('videoSummary',
folderVideos((int)$folder['id'], $userId)),
@@ -98,6 +170,7 @@ switch ($view) {
}
$folder = getFolder((int)$video['folder_id']);
$playlist = folderVideos((int)$folder['id'], $userId);
$crumbs = crumbList($folder);
$stmt = db()->prepare(
'SELECT position_seconds, completed FROM video_progress
@@ -118,10 +191,14 @@ switch ($view) {
}
echo json_encode([
'video' => ['id' => (int)$video['id'], 'name' => $video['name']],
'video' => ['id' => (int)$video['id'], 'name' => $video['name'],
'duration' => $video['duration_seconds'] !== null
? (float)$video['duration_seconds'] : null],
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
'breadcrumbs' => crumbList($folder),
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
'breadcrumbs' => $crumbs,
'playlist' => array_map('videoSummary', $playlist),
'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)),
'resume_at' => $resumeAt,
'prev_id' => $prevId,
'next_id' => $nextId,
@@ -129,6 +206,48 @@ switch ($view) {
]);
break;
case 'search':
$q = trim((string)($_GET['q'] ?? ''));
if ($q === '') {
echo json_encode(['query' => '', 'folders' => [], 'videos' => [], 'quizzes' => []]);
break;
}
$withPath = fn(array $row, array $summary) =>
$summary + ['folder_path' => $row['folder_path']];
echo json_encode([
'query' => $q,
'folders' => array_map(
fn($f) => courseSummary($userId, $f),
searchFolders($q)
),
'videos' => array_map(
fn($v) => $withPath($v, videoSummary($v)),
searchVideos($userId, $q)
),
'quizzes' => array_map(
fn($q2) => $withPath($q2, quizSummary($q2)),
searchQuizzes($userId, $q)
),
]);
break;
case 'settings':
$courses = courseCards($userId);
$withSome = array_values(array_filter($courses, fn($c) => $c['progress']['percent'] > 0));
echo json_encode([
'root' => rtrim(VIDEOS_ROOT, '/'),
'root_exists' => is_dir(VIDEOS_ROOT),
'totals' => libraryTotals(),
'last_scan' => appMetaGet('last_scan_at'),
'complete_threshold' => (int)round(100 * COMPLETE_THRESHOLD),
'registration_open' => (bool)ALLOW_REGISTRATION,
'username' => currentUsername(),
'progress_courses' => count($withSome),
'courses' => count($courses),
]);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Unknown view']);
+29
View File
@@ -0,0 +1,29 @@
<?php
// POST api/reset.php -> delete the logged-in user's progress for a scope.
// JSON body: {scope: all|folder|video|quiz, id?}
// Only ever touches the current user's rows; the library itself is untouched.
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json');
$userId = requireLoginApi();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true) ?: [];
$scope = (string)($data['scope'] ?? '');
$id = (int)($data['id'] ?? 0);
try {
$counts = resetProgress($userId, $scope, $id);
} catch (InvalidArgumentException $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
exit;
}
echo json_encode(['ok' => true, 'reset' => $counts]);
+1176 -304
View File
File diff suppressed because it is too large Load Diff
+1176 -21
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -18,3 +18,10 @@ define('COMPLETE_THRESHOLD', 0.95);
// Allow new users to self-register from the login page.
define('ALLOW_REGISTRATION', true);
// Where generated course thumbnails are cached. Must be writable by the PHP
// process; created automatically on first scan if missing.
define('THUMBNAILS_DIR', __DIR__ . '/cache/thumbnails');
// Capture each course's thumbnail this far into its first video (0.0-1.0).
define('THUMBNAIL_SEEK_PERCENT', 0.4);
+442 -10
View File
@@ -147,6 +147,7 @@ function scanLibrary(): array
$seenFolderIds = [];
$seenVideoIds = [];
$seenQuizIds = [];
$firstVideo = []; // courseFolderId => ['id' => videoId, 'path' => relPath]
$upsertFolder = $db->prepare(
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
@@ -172,9 +173,14 @@ function scanLibrary(): array
id = LAST_INSERT_ID(id)'
);
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
// $courseId identifies which top-level course folder the current directory
// sits under (null while walking VIDEOS_ROOT itself); it's set the moment
// we descend into a depth-0 folder and passed unchanged to every
// descendant call, so the first mp4 found anywhere in a course's subtree
// — in true directory-listing order — is recorded exactly once.
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth, ?int $courseId)
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$firstVideo): void {
$entries = scandir($dir) ?: [];
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
@@ -192,15 +198,19 @@ function scanLibrary(): array
$folderId = (int)$db->lastInsertId();
$seenFolderIds[] = $folderId;
$stats['folders']++;
$walk($full, $folderId, $rel, $depth + 1);
$walk($full, $folderId, $rel, $depth + 1, $courseId ?? $folderId);
} elseif (is_file($full) && strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'mp4') {
if ($parentId === null) {
continue; // mp4 directly in the root is skipped; use course folders
}
$name = pathinfo($entry, PATHINFO_FILENAME);
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
$seenVideoIds[] = (int)$db->lastInsertId();
$videoId = (int)$db->lastInsertId();
$seenVideoIds[] = $videoId;
$stats['videos']++;
if ($courseId !== null && !isset($firstVideo[$courseId])) {
$firstVideo[$courseId] = ['id' => $videoId, 'path' => $rel];
}
} elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) {
if ($parentId === null) {
continue;
@@ -219,7 +229,7 @@ function scanLibrary(): array
$db->beginTransaction();
try {
$walk($root, null, '', 0);
$walk($root, null, '', 0, null);
// Anything not seen this pass no longer exists on disk.
$flagMissing = function (string $table, array $seenIds) use ($db): int {
@@ -257,24 +267,152 @@ function scanLibrary(): array
}
}
// One thumbnail per course, captured from its first video (in the same
// order $firstVideo was built above) at THUMBNAIL_SEEK_PERCENT. Courses
// whose first video hasn't changed since last time are skipped.
$thumbStats = generateThumbnails($firstVideo);
$stats['thumbnails_generated'] = $thumbStats['generated'];
$stats['thumbnails_failed'] = $thumbStats['failed'];
appMetaSet('last_scan_at', gmdate('Y-m-d H:i:s'));
return $stats;
}
/* ---------------------------------------------------------------------------
* Course thumbnails — one frame captured from each course's first video (in
* scan order) at THUMBNAIL_SEEK_PERCENT of that video's duration, cached as a
* jpg named by folder id and served by thumb.php.
* ------------------------------------------------------------------------- */
/**
* folders.thumbnail_video_id/thumbnail_generated_at may not exist yet on a
* database created before this feature — self-heal the same way appMetaSet()
* lazily creates app_meta, so a git pull doesn't require a manual migration.
*/
function ensureThumbnailColumns(PDO $db): void
{
static $checked = false;
if ($checked) return;
try {
$db->query('SELECT thumbnail_video_id, thumbnail_generated_at FROM folders LIMIT 1');
} catch (Throwable $e) {
$db->exec('ALTER TABLE folders
ADD COLUMN thumbnail_video_id INT UNSIGNED NULL,
ADD COLUMN thumbnail_generated_at TIMESTAMP NULL');
}
$checked = true;
}
/**
* Generate (or refresh) each course's thumbnail from $firstVideo, the
* courseFolderId => ['id' => videoId, 'path' => relPath] map scanLibrary()
* builds while walking the tree. Returns ['generated' => n, 'skipped' => n,
* 'failed' => n].
*/
function generateThumbnails(array $firstVideo): array
{
$stats = ['generated' => 0, 'skipped' => 0, 'failed' => 0];
if (!$firstVideo) return $stats;
$dir = rtrim(THUMBNAILS_DIR, '/');
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
error_log("generateThumbnails: cannot create THUMBNAILS_DIR ($dir) — skipping");
$stats['failed'] = count($firstVideo);
return $stats;
}
$db = db();
ensureThumbnailColumns($db);
$upd = $db->prepare(
'UPDATE folders SET thumbnail_video_id = ?, thumbnail_generated_at = UTC_TIMESTAMP() WHERE id = ?'
);
foreach ($firstVideo as $courseId => $pick) {
$course = getFolder($courseId);
if (!$course) continue; // course itself went missing this scan
$thumbFile = "$dir/$courseId.jpg";
$alreadyCurrent = (int)($course['thumbnail_video_id'] ?? 0) === $pick['id'] && is_file($thumbFile);
if ($alreadyCurrent) {
$stats['skipped']++;
continue;
}
$abs = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $pick['path']);
$videoRoot = realpath(VIDEOS_ROOT);
if ($abs === false || $videoRoot === false
|| !str_starts_with($abs, $videoRoot . DIRECTORY_SEPARATOR)) {
$stats['failed']++;
continue;
}
$duration = probeDuration($abs);
$seekAt = ($duration !== null && $duration > 0) ? $duration * THUMBNAIL_SEEK_PERCENT : 1.0;
if (captureThumbnail($abs, $seekAt, $thumbFile)) {
$upd->execute([$pick['id'], $courseId]);
$stats['generated']++;
} else {
$stats['failed']++;
}
}
return $stats;
}
/**
* Grab a single frame from $videoPath at $seekSeconds via ffmpeg, cropped to
* a fixed 640x360 cover, and write it to $outFile. Written to a temp file
* first and renamed into place, so a request racing the write never sees a
* partial jpg. Returns false (leaving any existing thumbnail untouched) if
* ffmpeg is unavailable or the capture fails.
*/
function captureThumbnail(string $videoPath, float $seekSeconds, string $outFile): bool
{
if (!function_exists('shell_exec')) return false;
// The temp file has no recognizable extension, so the output format is
// forced explicitly with -f mjpeg rather than left for ffmpeg to guess
// from the filename (it can't, and fails outright without this).
$tmp = $outFile . '.tmp-' . bin2hex(random_bytes(4));
$cmd = sprintf(
'ffmpeg -y -ss %s -i %s -frames:v 1 -f mjpeg -vf %s -q:v 3 %s 2>&1',
escapeshellarg(number_format(max(0, $seekSeconds), 2, '.', '')),
escapeshellarg($videoPath),
escapeshellarg('scale=640:360:force_original_aspect_ratio=increase,crop=640:360'),
escapeshellarg($tmp)
);
$out = @shell_exec($cmd);
if (!is_file($tmp) || filesize($tmp) < 512) {
if (is_file($tmp)) @unlink($tmp);
if ($out !== null) {
error_log("captureThumbnail: ffmpeg produced no usable output for $videoPath\n$out");
}
return false;
}
return @rename($tmp, $outFile);
}
/* ---------------------------------------------------------------------------
* Progress helpers
* ------------------------------------------------------------------------- */
/**
* Progress for one folder's whole subtree (videos + quizzes), matched by
* path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
* Percent gives partial credit for half-watched videos and partial scores.
* One pass over a folder's whole subtree (videos + quizzes), matched by path
* prefix: how much is in there, how big, how long, and how far the user got.
* Course cards need all of it, so it is gathered in two queries rather than
* one per fact.
*/
function folderProgress(int $userId, string $folderPath): array
function folderRollup(int $userId, string $folderPath): array
{
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
$stmt = db()->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration,
COALESCE(SUM(v.size_bytes), 0) AS bytes,
COALESCE(SUM(vp.completed), 0) AS completed,
COALESCE(SUM(
CASE
@@ -315,19 +453,306 @@ function folderProgress(int $userId, string $folderPath): array
$total = (int)$videos['total'] + (int)$quizzes['total'];
$credit = $videos['credit'] + $quizzes['credit'];
return [
'videos' => (int)$videos['total'],
'quizzes' => (int)$quizzes['total'],
'duration' => (float)$videos['duration'],
'bytes' => (int)$videos['bytes'],
'total' => $total,
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
];
}
/**
* Progress for one folder's whole subtree.
* Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
* Percent gives partial credit for half-watched videos and partial scores.
*/
function folderProgress(int $userId, string $folderPath): array
{
$r = folderRollup($userId, $folderPath);
return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']];
}
/** A course (or section) as the library screens show it: contents + progress. */
function courseSummary(int $userId, array $folder): array
{
$r = folderRollup($userId, $folder['path']);
$percent = $r['percent'];
return [
'id' => (int)$folder['id'],
'name' => $folder['name'],
'path' => $folder['path'],
'videos' => $r['videos'],
'quizzes' => $r['quizzes'],
'duration' => $r['duration'],
'bytes' => $r['bytes'],
'added_at' => $folder['created_at'] ?? null,
'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent],
// The shelves the library groups by — the app has no course categories,
// so progress is what separates one shelf from another.
'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'),
];
}
/** Every top-level folder as a course card. */
function courseCards(int $userId): array
{
return array_map(fn($f) => courseSummary($userId, $f), childFolders(null));
}
/** Headline numbers for the home screen, computed from the course cards. */
function userStats(int $userId, array $courses): array
{
$inProgress = 0;
$finished = 0;
foreach ($courses as $c) {
if ($c['state'] === 'finished') $finished++;
elseif ($c['state'] === 'progress') $inProgress++;
}
// Watched time: full duration for completed videos, position for the rest.
$stmt = db()->prepare(
'SELECT COALESCE(SUM(
CASE WHEN vp.completed = 1
THEN COALESCE(v.duration_seconds, vp.duration_seconds, vp.position_seconds)
ELSE vp.position_seconds END
), 0) AS watched
FROM video_progress vp
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
WHERE vp.user_id = ?'
);
$stmt->execute([$userId]);
$watched = (float)$stmt->fetchColumn();
$stmt = db()->prepare(
'SELECT COUNT(*) AS taken,
COALESCE(AVG(qp.best_score / NULLIF(q.num_questions, 0)), 0) AS avg_score
FROM quiz_progress qp
JOIN quizzes q ON q.id = qp.quiz_id AND q.is_missing = 0
WHERE qp.user_id = ? AND qp.attempts > 0'
);
$stmt->execute([$userId]);
$quiz = $stmt->fetch();
return [
'in_progress' => $inProgress,
'finished' => $finished,
'courses' => count($courses),
'watched_seconds' => $watched,
'quizzes_taken' => (int)$quiz['taken'],
'quiz_average' => (int)round(100 * (float)$quiz['avg_score']),
];
}
/** Courses the scanner indexed most recently — the "found in the last scan" strip. */
function recentCourses(int $userId, int $limit = 3): array
{
$stmt = db()->prepare(
'SELECT * FROM folders
WHERE parent_id IS NULL AND is_missing = 0
ORDER BY created_at DESC, id DESC
LIMIT ' . (int)$limit
);
$stmt->execute();
$week = time() - 7 * 86400;
return array_map(function ($f) use ($userId, $week) {
$c = courseSummary($userId, $f);
$c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week;
return $c;
}, $stmt->fetchAll());
}
/** Library-wide counts for the sidebar and header. */
function libraryTotals(): array
{
$row = db()->query(
'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL AND is_missing = 0) AS courses,
(SELECT COUNT(*) FROM videos WHERE is_missing = 0) AS videos,
(SELECT COUNT(*) FROM quizzes WHERE is_missing = 0) AS quizzes,
(SELECT COALESCE(SUM(size_bytes), 0) FROM videos WHERE is_missing = 0) AS bytes,
(SELECT COALESCE(SUM(duration_seconds), 0) FROM videos WHERE is_missing = 0) AS duration'
)->fetch();
return [
'courses' => (int)$row['courses'],
'videos' => (int)$row['videos'],
'quizzes' => (int)$row['quizzes'],
'bytes' => (int)$row['bytes'],
'duration' => (float)$row['duration'],
];
}
/* ---------------------------------------------------------------------------
* app_meta — small key/value store (currently just the last scan time).
* Reads tolerate the table not existing yet, so an install created before this
* table was added keeps working until its next scan creates it.
* ------------------------------------------------------------------------- */
function appMetaGet(string $key): ?string
{
try {
$stmt = db()->prepare('SELECT v FROM app_meta WHERE k = ?');
$stmt->execute([$key]);
$value = $stmt->fetchColumn();
return $value === false ? null : (string)$value;
} catch (Throwable $e) {
return null;
}
}
function appMetaSet(string $key, string $value): void
{
$db = db();
$db->exec(
'CREATE TABLE IF NOT EXISTS app_meta (
k VARCHAR(64) PRIMARY KEY,
v VARCHAR(255) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB'
);
$stmt = $db->prepare('INSERT INTO app_meta (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)');
$stmt->execute([$key, $value]);
}
/** Escape LIKE wildcards in a path used as a prefix. */
function likePrefix(string $path): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
}
/** Escaped "contains" pattern for LIKE. */
function likeContains(string $term): string
{
return '%' . likePrefix($term) . '%';
}
/**
* Wipe the user's progress for a scope:
* all — everything the user has watched/answered
* folder — the folder's whole subtree (id = folder id)
* video — one video (id = video id)
* quiz — one quiz (id = quiz id)
* Returns ['videos' => n, 'quizzes' => n] rows removed.
* Rows for files that went missing are cleared too, so a reset is complete.
*/
function resetProgress(int $userId, string $scope, int $id = 0): array
{
$db = db();
switch ($scope) {
case 'all':
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ?');
$v->execute([$userId]);
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ?');
$q->execute([$userId]);
break;
case 'folder':
$folder = getFolder($id);
if (!$folder) {
throw new InvalidArgumentException('Folder not found');
}
$args = [$userId, $folder['path'], likePrefix($folder['path']) . '/%'];
$v = $db->prepare(
'DELETE vp FROM video_progress vp
JOIN videos v ON v.id = vp.video_id
JOIN folders f ON f.id = v.folder_id
WHERE vp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
);
$v->execute($args);
$q = $db->prepare(
'DELETE qp FROM quiz_progress qp
JOIN quizzes q ON q.id = qp.quiz_id
JOIN folders f ON f.id = q.folder_id
WHERE qp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
);
$q->execute($args);
break;
case 'video':
if ($id <= 0) {
throw new InvalidArgumentException('Video not found');
}
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ? AND video_id = ?');
$v->execute([$userId, $id]);
$q = null;
break;
case 'quiz':
if ($id <= 0) {
throw new InvalidArgumentException('Quiz not found');
}
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ? AND quiz_id = ?');
$q->execute([$userId, $id]);
$v = null;
break;
default:
throw new InvalidArgumentException('Unknown reset scope');
}
return [
'videos' => $v ? $v->rowCount() : 0,
'quizzes' => $q ? $q->rowCount() : 0,
];
}
/* ---------------------------------------------------------------------------
* Search — substring match on names (and on the relative path, so
* "Course 1/Intro" finds everything under it).
* ------------------------------------------------------------------------- */
function searchFolders(string $term, int $limit = 25): array
{
$like = likeContains($term);
$stmt = db()->prepare(
'SELECT * FROM folders
WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?)
ORDER BY depth, path
LIMIT ' . (int)$limit
);
$stmt->execute([$like, $like]);
return $stmt->fetchAll();
}
function searchVideos(int $userId, string $term, int $limit = 50): array
{
$like = likeContains($term);
$stmt = db()->prepare(
'SELECT v.*, f.path AS folder_path,
vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed
FROM videos v
JOIN folders f ON f.id = v.folder_id
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
WHERE v.is_missing = 0 AND f.is_missing = 0 AND (v.name LIKE ? OR v.path LIKE ?)
ORDER BY f.path, v.sort_order, v.name
LIMIT ' . (int)$limit
);
$stmt->execute([$userId, $like, $like]);
return $stmt->fetchAll();
}
function searchQuizzes(int $userId, string $term, int $limit = 25): array
{
$like = likeContains($term);
$stmt = db()->prepare(
'SELECT q.*, f.path AS folder_path,
qp.best_score, qp.last_score, qp.attempts, qp.completed
FROM quizzes q
JOIN folders f ON f.id = q.folder_id
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
WHERE q.is_missing = 0 AND f.is_missing = 0 AND (q.name LIKE ? OR q.path LIKE ?)
ORDER BY f.path, q.sort_order, q.name
LIMIT ' . (int)$limit
);
$stmt->execute([$userId, $like, $like]);
return $stmt->fetchAll();
}
function getFolder(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM folders WHERE id = ? AND is_missing = 0');
@@ -423,7 +848,7 @@ function folderBreadcrumbs(array $folder): array
function continueWatching(int $userId, int $limit = 6): array
{
$stmt = db()->prepare(
'SELECT v.id, v.name, f.path AS folder_path,
'SELECT v.id, v.name, v.folder_id, f.path AS folder_path,
vp.position_seconds, vp.duration_seconds, vp.updated_at
FROM video_progress vp
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
@@ -436,6 +861,13 @@ function continueWatching(int $userId, int $limit = 6): array
return $stmt->fetchAll();
}
/** The top-level course a folder belongs to (itself, if it's already top-level). */
function courseIdOf(array $folder): int
{
$crumbs = folderBreadcrumbs($folder);
return (int)$crumbs[0]['id'];
}
function formatDuration(?float $seconds): string
{
if ($seconds === null || $seconds <= 0) return '';
+66 -27
View File
@@ -1,46 +1,85 @@
<?php /* CourseHub SPA shell — all data flows through api/ via fetch. */ ?>
<!doctype html>
<html lang="en" data-bs-theme="dark">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#F2F0EA">
<title>CourseHub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.css" rel="stylesheet">
<link href="assets/style.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom mb-4">
<div class="container">
<a class="navbar-brand fw-bold" href="#/">
<i class="bi bi-collection-play-fill text-primary me-1"></i>CourseHub
</a>
<div id="nav-user" class="d-none d-flex align-items-center gap-3">
<button id="btn-rescan" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-repeat me-1"></i>Rescan library
</button>
<span class="text-secondary small">
<i class="bi bi-person-circle me-1"></i><span id="nav-username"></span>
<div class="app" id="app-shell" hidden>
<aside class="sidebar">
<a class="brand" href="#/">
<span class="brand-mark">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
</span>
<button id="btn-logout" class="btn btn-sm btn-outline-danger">Logout</button>
</div>
</div>
</nav>
<span class="brand-name">CourseHub</span>
</a>
<main class="container pb-5">
<div id="app">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</main>
<nav class="nav" id="nav-main"></nav>
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
<div class="sidebar-rule"></div>
<div class="sidebar-label">Shelves</div>
<div class="shelf-list" id="nav-shelves"></div>
<div class="sidebar-spacer"></div>
<div class="scan-card">
<div class="scan-card-head">
<span>Library scanned</span>
<span class="mono" id="scan-when">—</span>
</div>
<button class="btn btn-accent btn-block" id="btn-scan" data-scan>
<span class="scan-spin" id="scan-spin">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path></svg>
</span><span id="scan-label">Scan library</span>
</button>
</div>
</aside>
<main class="main">
<header class="topbar">
<label class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#807A70" stroke-width="1.7" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.2-3.2"></path></svg>
<input id="search-input" type="search" autocomplete="off" placeholder="Search courses and videos" aria-label="Search the library">
<button id="btn-search-clear" class="icon-btn" type="button" title="Clear search" hidden>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</label>
<button class="icon-btn scan-mobile" id="btn-scan-mobile" data-scan title="Scan library">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path></svg>
</button>
<div class="topbar-end">
<span class="mono topbar-totals" id="topbar-totals"></span>
<button class="avatar" id="btn-account" title="Account"></button>
</div>
</header>
<div class="view" id="view">
<div class="loading"><span class="spinner"></span></div>
</div>
</main>
<nav class="tabbar" id="nav-tabs"></nav>
</div>
<!-- Holds the boot spinner until we know whether there is a session, then
becomes either the sign-in card or is hidden in favour of the shell. -->
<div class="auth" id="auth">
<div class="loading"><span class="spinner"></span></div>
</div>
<div class="toasts" id="toasts"></div>
<div id="overlay-root"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/animejs@3.2.2/lib/anime.min.js"></script>
<script src="assets/app.js"></script>
</body>
</html>
+2 -1
View File
@@ -11,4 +11,5 @@ if (PHP_SAPI !== 'cli') {
$stats = scanLibrary();
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes"
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n";
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n"
. "Thumbnails: {$stats['thumbnails_generated']} generated, {$stats['thumbnails_failed']} failed\n";
+14
View File
@@ -9,8 +9,20 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- Small key/value store for app-wide facts (currently: when the last scan ran).
CREATE TABLE IF NOT EXISTS app_meta (
k VARCHAR(64) PRIMARY KEY,
v VARCHAR(255) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- One row per directory found under VIDEOS_ROOT.
-- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro".
-- thumbnail_video_id/thumbnail_generated_at are only ever set on top-level
-- (course) folders — see generateThumbnails() — and have no FK to videos
-- (that would make folders<->videos a circular reference); a stale pointer
-- is harmless and self-corrects on the next scan.
CREATE TABLE IF NOT EXISTS folders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
parent_id INT UNSIGNED NULL,
@@ -19,6 +31,8 @@ CREATE TABLE IF NOT EXISTS folders (
depth INT UNSIGNED NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
is_missing TINYINT(1) NOT NULL DEFAULT 0,
thumbnail_video_id INT UNSIGNED NULL,
thumbnail_generated_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
REFERENCES folders(id) ON DELETE CASCADE,
+43
View File
@@ -0,0 +1,43 @@
<?php
// Serves a cached course thumbnail — a frame captured from the course's first
// video at THUMBNAIL_SEEK_PERCENT, generated by the scanner (see
// generateThumbnails() in includes/functions.php). Looked up by folder ID
// only, mirroring stream.php's containment check; 404s (no thumbnail yet, or
// the id isn't a course) are expected and handled by the client falling back
// to its colour-block placeholder.
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/functions.php';
$userId = currentUserId();
if ($userId === null) {
http_response_code(403);
exit('Forbidden');
}
$courseId = (int)($_GET['course_id'] ?? 0);
$dir = realpath(THUMBNAILS_DIR);
$file = ($courseId > 0 && $dir !== false)
? realpath($dir . '/' . $courseId . '.jpg')
: false;
if ($dir === false || $file === false || !str_starts_with($file, $dir . DIRECTORY_SEPARATOR)
|| !is_file($file)) {
http_response_code(404);
exit('No thumbnail');
}
$mtime = filemtime($file);
$etag = '"' . md5($courseId . '-' . $mtime) . '"';
header('Cache-Control: private, max-age=86400');
header('ETag: ' . $etag);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? null) === $etag) {
http_response_code(304);
exit;
}
header('Content-Type: image/jpeg');
header('Content-Length: ' . filesize($file));
readfile($file);