diff --git a/README.md b/README.md index 4d26cb1..28fd0ce 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly. │ └── layout.php ← Shared HTML head, topbar, footer, pagination ├── admin/ │ ├── index.php ← Video list with search + pagination -│ ├── add.php ← Add new video with multi-format sources -│ ├── edit.php ← Edit video metadata + manage sources +│ ├── add.php ← Add new video with uploaded or external URL sources +│ ├── edit.php ← Edit video metadata + manage local/remote sources │ ├── settings.php ← Site settings, per-page count, password │ ├── login.php ← Admin login │ ├── logout.php ← Session destroy + redirect @@ -63,12 +63,21 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly. 1. Go to `admin/add.php` 2. Fill in title, description, duration, sort order 3. Upload a thumbnail (JPG/PNG/WebP) -4. Upload one or more video files — each can be a different format or quality: +4. Add one or more video sources. Each source can be either: + - an uploaded local video file, stored under `media/videos/` + - an external video URL, stored in SQLite and played directly by URL +5. For uploaded files, supported formats include: - **MP4** (H.264) — widest compatibility - **WebM** (VP9) — smaller size, modern browsers - **OGV** — Firefox fallback (legacy) -5. Set quality labels (1080p, 720p, 480p, etc.) -6. Save — Video.js will auto-select the best format the browser supports +6. Set quality labels (1080p, 720p, 480p, etc.) +7. Save — Video.js will use the stored source location and auto-select the best format the browser supports + +External URLs must use `http://` or `https://`. Uploaded files and remote URLs can be mixed on the same video. + +### Public Search + +The full catalogue page includes a public search for published videos only. Search matches video title, description, and slug; draft videos remain hidden. ### Catalogue Per-Page @@ -92,6 +101,7 @@ Use the **Sort Order** field — lower numbers appear first. Default is 0. - Custom skin matching the site's dark purple/green theme - Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2× - Multiple source fallback — browser picks best format automatically +- Local uploaded sources and remote URL sources - Thumbnail posters - Keyboard accessible diff --git a/admin/add.php b/admin/add.php index 936b82d..2f402b3 100644 --- a/admin/add.php +++ b/admin/add.php @@ -45,42 +45,76 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $video_id = (int)$db->lastInsertId(); - // Handle video source files + // Handle local upload and remote URL sources + $source_types = $_POST['source_type'] ?? []; $source_labels = $_POST['source_label'] ?? []; $source_quality = $_POST['source_quality'] ?? []; $source_order = $_POST['source_sort'] ?? []; + $source_urls = $_POST['source_url'] ?? []; $source_files = $_FILES['source_file'] ?? []; $videos_dir = MEDIA_DIR . 'videos/'; if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true); $inserted_sources = 0; + $moved_files = []; $allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi']; - if (!empty($source_files['tmp_name'])) { - foreach ($source_files['tmp_name'] as $i => $tmp) { - if (empty($tmp) || !is_uploaded_file($tmp)) continue; - $orig_name = $source_files['name'][$i]; - $ext = strtolower(pathinfo($orig_name, PATHINFO_EXTENSION)); - if (!in_array($ext, $allowed_video)) continue; + $source_count = max( + count($source_types), + count($source_labels), + count($source_urls), + isset($source_files['tmp_name']) && is_array($source_files['tmp_name']) ? count($source_files['tmp_name']) : 0 + ); - $fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext; - if (move_uploaded_file($tmp, $videos_dir . $fname)) { - $label = trim($source_labels[$i] ?? ''); - $quality = trim($source_quality[$i] ?? ''); - $mime = mime_from_ext($fname); - $sort = (int)($source_order[$i] ?? $i); - $db->prepare(" - INSERT INTO video_sources (video_id, label, file_path, mime_type, quality, sort_order) - VALUES (?,?,?,?,?,?) - ")->execute([$video_id, $label, 'videos/' . $fname, $mime, $quality, $sort]); - $inserted_sources++; + for ($i = 0; $i < $source_count; $i++) { + $type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local'; + $label = trim($source_labels[$i] ?? ''); + $quality = trim($source_quality[$i] ?? ''); + $sort = (int)($source_order[$i] ?? $i); + + if ($type === 'remote') { + $url = normalize_media_url($source_urls[$i] ?? ''); + if ($url === '') { + if (trim($source_urls[$i] ?? '') !== '') $error = 'External video URLs must start with http:// or https://.'; + continue; } + + $db->prepare(" + INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order) + VALUES (?,?,?,?,?,?,?,?) + ")->execute([$video_id, $label, 'remote', '', $url, mime_from_ext($url), $quality, $sort]); + $inserted_sources++; + continue; + } + + $tmp = $source_files['tmp_name'][$i] ?? ''; + if (empty($tmp) || !is_uploaded_file($tmp)) continue; + $orig_name = $source_files['name'][$i] ?? ''; + $ext = strtolower(pathinfo($orig_name, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowed_video, true)) continue; + + $fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext; + if (move_uploaded_file($tmp, $videos_dir . $fname)) { + $moved_files[] = $videos_dir . $fname; + $db->prepare(" + INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order) + VALUES (?,?,?,?,?,?,?,?) + ")->execute([$video_id, $label, 'local', 'videos/' . $fname, '', mime_from_ext($fname), $quality, $sort]); + $inserted_sources++; } } + if ($error) { + foreach ($moved_files as $moved) { + if (file_exists($moved)) unlink($moved); + } + if ($thumbnail && file_exists(MEDIA_DIR . $thumbnail)) unlink(MEDIA_DIR . $thumbnail); + $db->prepare("DELETE FROM videos WHERE id=?")->execute([$video_id]); + } else { header('Location: edit.php?id=' . $video_id . '&saved=1'); exit; + } } } } @@ -94,11 +128,13 @@ render_head('Add Video — Admin', ' .source-row { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r); padding:1rem; - display:grid; grid-template-columns:1fr 1fr 1fr auto; + display:grid; grid-template-columns:140px minmax(220px,1.5fr) 1fr 1fr 80px auto; gap:.75rem; align-items:end; } - @media(max-width:700px) { .source-row { grid-template-columns:1fr 1fr; } } + @media(max-width:900px) { .source-row { grid-template-columns:1fr 1fr; } } + @media(max-width:560px) { .source-row { grid-template-columns:1fr; } } .add-source-btn { align-self:flex-start; } + .source-location-panel[hidden] { display:none; } .section-divider { border:none; border-top:1px solid var(--border); margin:1.5rem 0 .5rem; @@ -221,21 +257,32 @@ render_head('Add Video — Admin', '
-

Video Sources (Multiple Formats)

+

Video Sources

- Upload the same video in multiple formats/qualities. The browser will automatically pick the best one it can play. - Accepted: MP4, WebM, OGV, MOV, MKV, AVI. + Upload a video file or link to an external video URL. The database stores where each source lives, and the player uses that location at playback time. + Uploaded files: MP4, WebM, OGV, MOV, MKV, AVI. Remote URLs must use http:// or https://.

+ + +
+
+
+ + +
+ + +
+ + +
+ + + +
+
+
+ + + Clear + +
-

no videos in the catalogue yet

+

@@ -117,7 +133,7 @@ render_head('Videos — TyClifford.com', ' 1): ?> diff --git a/embed.php b/embed.php index 2507e99..997cba8 100644 --- a/embed.php +++ b/embed.php @@ -190,7 +190,7 @@ header('Content-Security-Policy: frame-ancestors *'); >

Your browser does not support HTML5 video.

diff --git a/includes/db.php b/includes/db.php index 183861b..a29a8b5 100644 --- a/includes/db.php +++ b/includes/db.php @@ -44,7 +44,9 @@ function get_db(): PDO { id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT '', - file_path TEXT NOT NULL, + source_type TEXT NOT NULL DEFAULT 'local', + file_path TEXT NOT NULL DEFAULT '', + source_url TEXT NOT NULL DEFAULT '', mime_type TEXT NOT NULL DEFAULT 'video/mp4', quality TEXT NOT NULL DEFAULT '720p', sort_order INTEGER NOT NULL DEFAULT 0 @@ -52,9 +54,13 @@ function get_db(): PDO { CREATE INDEX IF NOT EXISTS idx_videos_published ON videos(published); CREATE INDEX IF NOT EXISTS idx_videos_sort ON videos(sort_order, id); + CREATE INDEX IF NOT EXISTS idx_videos_title ON videos(title); CREATE INDEX IF NOT EXISTS idx_sources_video ON video_sources(video_id); "); + ensure_video_source_schema($pdo); + $pdo->exec("CREATE INDEX IF NOT EXISTS idx_sources_type ON video_sources(source_type)"); + // Default settings $defaults = [ 'catalogue_per_page' => '10', @@ -68,6 +74,20 @@ function get_db(): PDO { return $pdo; } +function ensure_video_source_schema(PDO $pdo): void { + $cols = $pdo->query("PRAGMA table_info(video_sources)")->fetchAll(); + $names = array_column($cols, 'name'); + + if (!in_array('source_type', $names, true)) { + $pdo->exec("ALTER TABLE video_sources ADD COLUMN source_type TEXT NOT NULL DEFAULT 'local'"); + } + if (!in_array('source_url', $names, true)) { + $pdo->exec("ALTER TABLE video_sources ADD COLUMN source_url TEXT NOT NULL DEFAULT ''"); + } + + $pdo->exec("UPDATE video_sources SET source_type='local' WHERE source_type='' OR source_type IS NULL"); +} + function setting(string $key, string $fallback = ''): string { $row = get_db()->prepare("SELECT value FROM settings WHERE key=?"); $row->execute([$key]); @@ -94,18 +114,55 @@ function make_slug(string $title, int $id = 0): string { } function mime_from_ext(string $path): string { + $url_path = parse_url($path, PHP_URL_PATH); + if (is_string($url_path) && $url_path !== '') { + $path = $url_path; + } $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); return match($ext) { 'mp4' => 'video/mp4', + 'm4v' => 'video/mp4', 'webm' => 'video/webm', 'ogv' => 'video/ogg', + 'ogg' => 'video/ogg', 'mov' => 'video/quicktime', 'mkv' => 'video/x-matroska', 'avi' => 'video/x-msvideo', + 'm3u8' => 'application/vnd.apple.mpegurl', + 'mpd' => 'application/dash+xml', default => 'video/mp4', }; } +function normalize_media_url(string $url): string { + $url = trim($url); + if ($url === '') return ''; + if (str_starts_with($url, '//')) $url = 'https:' . $url; + if (!filter_var($url, FILTER_VALIDATE_URL)) return ''; + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + return in_array($scheme, ['http', 'https'], true) ? $url : ''; +} + +function source_is_remote(array $source): bool { + return ($source['source_type'] ?? 'local') === 'remote'; +} + +function source_is_local(array $source): bool { + return !source_is_remote($source); +} + +function source_location(array $source): string { + return source_is_remote($source) ? ($source['source_url'] ?? '') : ($source['file_path'] ?? ''); +} + +function source_public_url(array $source, string $media_base = MEDIA_URL): string { + if (source_is_remote($source)) { + return $source['source_url'] ?? ''; + } + + return rtrim($media_base, '/') . '/' . ltrim($source['file_path'] ?? '', '/'); +} + function format_duration(int $seconds): string { if ($seconds <= 0) return ''; $h = intdiv($seconds, 3600); @@ -119,27 +176,47 @@ function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } -function get_videos_paginated(int $page, int $per_page, bool $published_only = true): array { +function get_videos_paginated(int $page, int $per_page, bool $published_only = true, string $search = ''): array { $db = get_db(); - $where = $published_only ? 'WHERE v.published=1' : ''; + $where_parts = []; + $bind = []; + $search = trim($search); + if ($published_only) { + $where_parts[] = 'v.published=1'; + } + if ($search !== '') { + $where_parts[] = '(v.title LIKE :q OR v.description LIKE :q OR v.slug LIKE :q)'; + $bind[':q'] = '%' . $search . '%'; + } + $where = $where_parts ? 'WHERE ' . implode(' AND ', $where_parts) : ''; + $count = $db->prepare("SELECT COUNT(*) FROM videos v $where"); + foreach ($bind as $key => $value) $count->bindValue($key, $value); + $count->execute(); + $total = $count->fetchColumn(); + $total_pages = (int)ceil($total / $per_page); + if ($total_pages > 0) { + $page = min($page, $total_pages); + } $offset = ($page - 1) * $per_page; - $total = $db->query("SELECT COUNT(*) FROM videos v $where")->fetchColumn(); $rows = $db->prepare(" - SELECT v.*, GROUP_CONCAT(vs.file_path,'||') AS source_paths + SELECT v.*, GROUP_CONCAT(CASE WHEN vs.source_type='remote' THEN vs.source_url ELSE vs.file_path END,'||') AS source_paths FROM videos v LEFT JOIN video_sources vs ON vs.video_id = v.id $where GROUP BY v.id ORDER BY v.sort_order ASC, v.id DESC - LIMIT ? OFFSET ? + LIMIT :limit OFFSET :offset "); - $rows->execute([$per_page, $offset]); + foreach ($bind as $key => $value) $rows->bindValue($key, $value); + $rows->bindValue(':limit', $per_page, PDO::PARAM_INT); + $rows->bindValue(':offset', $offset, PDO::PARAM_INT); + $rows->execute(); return [ 'videos' => $rows->fetchAll(), 'total' => (int)$total, 'page' => $page, 'per_page' => $per_page, - 'total_pages' => (int)ceil($total / $per_page), + 'total_pages' => $total_pages, ]; } diff --git a/index.php b/index.php index 6e270cc..5874fc7 100644 --- a/index.php +++ b/index.php @@ -27,6 +27,9 @@ render_head('Media — TyClifford.com', ' /* ── Catalogue grid ── */ .cat-section { display:flex; flex-direction:column; gap:1rem; } .cat-header { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; } + .cat-search { display:flex; gap:.5rem; flex:1; min-width:240px; max-width:430px; } + .cat-search input { flex:1; min-width:0; } + @media(max-width:560px) { .cat-search { min-width:100%; max-width:none; } } .cat-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); @@ -220,7 +223,7 @@ render_head('Media — TyClifford.com', ' data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'> - +

Your browser does not support HTML video.

@@ -286,6 +289,13 @@ render_head('Media — TyClifford.com', '

Video Catalogue

+ Browse all →
@@ -339,7 +349,7 @@ render_head('Media — TyClifford.com', '