256 lines
11 KiB
Markdown
256 lines
11 KiB
Markdown
# CourseHub
|
||
|
||
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 (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).
|
||
- Per-user **resume**: log in from any device and continue where you left off.
|
||
- **Progress tracking at every level** — each folder shows completed/total and a
|
||
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).
|
||
|
||
## Layout on disk
|
||
|
||
```
|
||
videos/
|
||
├── Course 1/
|
||
│ ├── Intro/
|
||
│ │ ├── Video1.mp4
|
||
│ │ └── Video2.mp4
|
||
│ └── Module 2/
|
||
│ └── Advanced/
|
||
│ └── lesson.mp4
|
||
└── Course 2/
|
||
└── ...
|
||
```
|
||
|
||
Top-level folders become courses on the home page.
|
||
|
||
## Setup
|
||
|
||
1. **Database** — create the schema and an app user:
|
||
|
||
```bash
|
||
sudo mysql < schema.sql
|
||
sudo mysql -e "CREATE USER IF NOT EXISTS 'coursehub'@'localhost' IDENTIFIED BY 'coursehub';
|
||
GRANT ALL ON coursehub.* TO 'coursehub'@'localhost';"
|
||
```
|
||
|
||
2. **Configure** — edit `config.php`:
|
||
- `VIDEOS_ROOT`: absolute path to your course folders (must be readable by
|
||
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:
|
||
|
||
```bash
|
||
php -S 0.0.0.0:8080 -t /path/to/coursehub
|
||
```
|
||
|
||
or point an Apache/Nginx + PHP-FPM vhost at the directory.
|
||
|
||
4. **Index the library** — click **Rescan library** in the top bar, or:
|
||
|
||
```bash
|
||
php scan.php
|
||
```
|
||
|
||
Rescan whenever you add/remove files. Re-scans are idempotent: existing
|
||
videos keep their IDs (and everyone's progress); files that disappeared are
|
||
hidden, not deleted, so progress survives if they come back.
|
||
|
||
5. Open the site, create an account, and watch.
|
||
|
||
## Quizzes
|
||
|
||
Course folders may contain quiz text files next to the videos. Files named
|
||
`*_quiz_solved.txt` are indexed (a `*_quiz_notsolved.txt` twin is ignored —
|
||
it's the same content without answers). Format:
|
||
|
||
```
|
||
[&] Which two are supported CLI connectivity methods? (Select two)
|
||
- RDP
|
||
- rlogin
|
||
- GUI widget -- Correct
|
||
- FortiExplorer -- Correct
|
||
```
|
||
|
||
Questions start with `[&]`, options with `- `, and correct options end with
|
||
`-- Correct`. Questions with more than one correct option automatically render
|
||
as checkboxes instead of radios.
|
||
|
||
Quizzes are graded server-side (answers are never sent to the browser before
|
||
submission). A question counts only if the selected set exactly matches the
|
||
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`
|
||
every 5 s while playing, plus on pause/seek, via `navigator.sendBeacon`
|
||
when the tab is hidden or closed, and when navigating to another view
|
||
in the SPA.
|
||
- A video is marked **completed** at ≥ 95 % watched (`COMPLETE_THRESHOLD`).
|
||
Completion is sticky — rewatching doesn't un-complete it.
|
||
- Folder progress is computed over the folder's entire subtree using a path
|
||
prefix match, with partial credit for videos in progress.
|
||
- Video duration is read at scan time by parsing the mp4's `mvhd` header in
|
||
pure PHP (ffprobe fallback if the parse fails), so listings show durations
|
||
before anyone plays the video. The browser still reports the duration on
|
||
first play as a last-resort fallback.
|
||
|
||
## Production notes
|
||
|
||
- PHP streams the video in 512 KB chunks with Range support, which is fine for
|
||
a handful of users. For heavier use, serve files with `X-Accel-Redirect`
|
||
(Nginx) or `X-Sendfile` (Apache) instead of streaming through PHP.
|
||
- Put the app behind HTTPS and set `session.cookie_secure=1`.
|
||
- Deny direct web access to `includes/`, `config.php`, and `schema.sql`
|
||
(or place the videos and includes outside the docroot).
|