Files
coursehub/README.md
T
Harwinder Sekhon 31b01a1a18 UI improved
2026-07-27 13:58:47 -04:00

11 KiB
Raw Blame History

CourseHub

Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design system, Plyr and anime.js. 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:

    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:

    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:

    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.

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).