ff9a351091
Course cards go back to the colour-block art that previously sat under the captured frame, so the UI is unchanged apart from losing the photo. - Drop thumb.php, generateThumbnails(), captureThumbnail() and ensureThumbnailColumns(), plus the first-video tracking the scanner only kept to pick a frame source. - Drop the ffprobe fallback in probeDuration(); the pure-PHP mvhd parse is now the only scan-time probe, with the player's report on first play as the remaining fallback. - Drop THUMBNAILS_DIR/THUMBNAIL_SEEK_PERCENT from config, the thumbnail_* columns from schema.sql, the thumbnail counters from scan output, .thumb img from the stylesheet, and the matching README parts. Existing databases keep two now-unused folders.thumbnail_* columns; they are inert and can be dropped by hand if wanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ
223 lines
9.4 KiB
Markdown
223 lines
9.4 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) is a plain HTTP resource.
|
|
|
|
- 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.
|
|
- 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.
|
|
|
|
No external binaries are needed — PHP with PDO/MySQL is the whole
|
|
dependency list.
|
|
|
|
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.
|
|
|
|
## 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 card art, 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, so listings show durations before anyone plays the video. If a file
|
|
can't be parsed, the browser reports its duration on first play instead.
|
|
|
|
## 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).
|