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:
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// GET api/auth.php -> {logged_in, username}
|
||||
// POST api/auth.php {action, username?, password?}
|
||||
// action: login | register | logout
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
echo json_encode([
|
||||
'logged_in' => currentUserId() !== null,
|
||||
'username' => currentUsername(),
|
||||
'allow_registration' => ALLOW_REGISTRATION,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
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) ?: [];
|
||||
$action = $data['action'] ?? '';
|
||||
$username = trim($data['username'] ?? '');
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'login':
|
||||
if (attemptLogin($username, $password)) {
|
||||
echo json_encode(['ok' => true, 'username' => currentUsername()]);
|
||||
} else {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Invalid username or password.']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'register':
|
||||
if (!ALLOW_REGISTRATION) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Registration is disabled.']);
|
||||
break;
|
||||
}
|
||||
$result = registerUser($username, $password);
|
||||
if ($result === true) {
|
||||
attemptLogin($username, $password);
|
||||
echo json_encode(['ok' => true, 'username' => currentUsername()]);
|
||||
} else {
|
||||
http_response_code(422);
|
||||
echo json_encode(['error' => $result]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'logout':
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
echo json_encode(['ok' => true]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Unknown action']);
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
// GET api/library.php?view=home -> courses + continue-watching
|
||||
// GET api/library.php?view=folder&id=N -> folder contents + progress
|
||||
// GET api/library.php?view=video&id=N -> video + playlist + resume info
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/functions.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
$userId = requireLoginApi();
|
||||
|
||||
function folderSummary(array $f, int $userId): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$f['id'],
|
||||
'name' => $f['name'],
|
||||
'progress' => folderProgress($userId, $f['path']),
|
||||
];
|
||||
}
|
||||
|
||||
function videoSummary(array $v): array
|
||||
{
|
||||
$duration = $v['duration_seconds'] ?? $v['watched_duration'] ?? null;
|
||||
$position = (float)($v['position_seconds'] ?? 0);
|
||||
return [
|
||||
'id' => (int)$v['id'],
|
||||
'name' => $v['name'],
|
||||
'duration' => $duration !== null ? (float)$duration : null,
|
||||
'position' => $position,
|
||||
'completed' => (bool)($v['completed'] ?? false),
|
||||
'percent' => ($duration ?? 0) > 0 ? min(100, (int)round(100 * $position / $duration)) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
function crumbList(array $folder): array
|
||||
{
|
||||
return array_map(
|
||||
fn($c) => ['id' => (int)$c['id'], 'name' => $c['name']],
|
||||
folderBreadcrumbs($folder)
|
||||
);
|
||||
}
|
||||
|
||||
$view = $_GET['view'] ?? 'home';
|
||||
|
||||
switch ($view) {
|
||||
case 'home':
|
||||
echo json_encode([
|
||||
'courses' => array_map(fn($c) => folderSummary($c, $userId), childFolders(null)),
|
||||
'continue' => array_map(fn($r) => [
|
||||
'id' => (int)$r['id'],
|
||||
'name' => $r['name'],
|
||||
'folder_path' => $r['folder_path'],
|
||||
'position' => (float)$r['position_seconds'],
|
||||
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
|
||||
], continueWatching($userId)),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'folder':
|
||||
$folder = getFolder((int)($_GET['id'] ?? 0));
|
||||
if (!$folder) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Folder not found']);
|
||||
break;
|
||||
}
|
||||
echo json_encode([
|
||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
||||
'breadcrumbs' => crumbList($folder),
|
||||
'progress' => folderProgress($userId, $folder['path']),
|
||||
'subfolders' => array_map(fn($f) => folderSummary($f, $userId),
|
||||
childFolders((int)$folder['id'])),
|
||||
'videos' => array_map('videoSummary',
|
||||
folderVideos((int)$folder['id'], $userId)),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'video':
|
||||
$video = getVideo((int)($_GET['id'] ?? 0));
|
||||
if (!$video) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Video not found']);
|
||||
break;
|
||||
}
|
||||
$folder = getFolder((int)$video['folder_id']);
|
||||
$playlist = folderVideos((int)$folder['id'], $userId);
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT position_seconds, completed FROM video_progress
|
||||
WHERE user_id = ? AND video_id = ?'
|
||||
);
|
||||
$stmt->execute([$userId, (int)$video['id']]);
|
||||
$saved = $stmt->fetch() ?: null;
|
||||
$resumeAt = ($saved && !$saved['completed'] && $saved['position_seconds'] > 5)
|
||||
? (float)$saved['position_seconds'] : 0;
|
||||
|
||||
$prevId = $nextId = null;
|
||||
foreach ($playlist as $i => $p) {
|
||||
if ((int)$p['id'] === (int)$video['id']) {
|
||||
$prevId = isset($playlist[$i - 1]) ? (int)$playlist[$i - 1]['id'] : null;
|
||||
$nextId = isset($playlist[$i + 1]) ? (int)$playlist[$i + 1]['id'] : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'video' => ['id' => (int)$video['id'], 'name' => $video['name']],
|
||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
||||
'breadcrumbs' => crumbList($folder),
|
||||
'playlist' => array_map('videoSummary', $playlist),
|
||||
'resume_at' => $resumeAt,
|
||||
'prev_id' => $prevId,
|
||||
'next_id' => $nextId,
|
||||
'stream_url' => 'stream.php?id=' . (int)$video['id'],
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Unknown view']);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// GET api/progress.php?video_id=N -> saved progress for this user/video
|
||||
// POST api/progress.php -> save progress
|
||||
// JSON body: {video_id, position, duration}
|
||||
// (also accepts form-encoded fields; used by navigator.sendBeacon)
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/functions.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
$userId = requireLoginApi();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$videoId = (int)($_GET['video_id'] ?? 0);
|
||||
$stmt = db()->prepare(
|
||||
'SELECT position_seconds, duration_seconds, completed
|
||||
FROM video_progress WHERE user_id = ? AND video_id = ?'
|
||||
);
|
||||
$stmt->execute([$userId, $videoId]);
|
||||
echo json_encode($stmt->fetch() ?: ['position_seconds' => 0, 'duration_seconds' => null, 'completed' => 0]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
parse_str($raw, $data); // sendBeacon posts form-encoded
|
||||
}
|
||||
|
||||
$videoId = (int)($data['video_id'] ?? 0);
|
||||
$position = max(0, (float)($data['position'] ?? 0));
|
||||
$duration = (float)($data['duration'] ?? 0);
|
||||
|
||||
if ($videoId <= 0 || !getVideo($videoId)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Unknown video']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$completed = ($duration > 0 && $position / $duration >= COMPLETE_THRESHOLD) ? 1 : 0;
|
||||
|
||||
$db = db();
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO video_progress (user_id, video_id, position_seconds, duration_seconds, completed)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
position_seconds = VALUES(position_seconds),
|
||||
duration_seconds = VALUES(duration_seconds),
|
||||
completed = GREATEST(completed, VALUES(completed))' // once complete, stays complete
|
||||
);
|
||||
$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]);
|
||||
|
||||
// Cache the duration on the video row so listings can show it without ffprobe.
|
||||
if ($duration > 0) {
|
||||
$db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL')
|
||||
->execute([$duration, $videoId]);
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'completed' => (bool)$completed]);
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// POST api/scan.php -> rescan the library, returns stats.
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/functions.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
requireLoginApi();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
echo json_encode(['ok' => true, 'stats' => scanLibrary()]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
Reference in New Issue
Block a user