diff --git a/README.md b/README.md index 093a625..4d26cb1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,118 @@ -# mediaplayer +# TyClifford.com — Media Player -A media player. \ No newline at end of file +A self-hosted multimedia player website built with PHP + SQLite + Video.js. +Styled to match the existing `gate.php` / `index.php` dark theme exactly. + +--- + +## Requirements + +- PHP 8.0+ with `pdo_sqlite`, `fileinfo` extensions +- Write permissions on the `data/` and `media/` directories + +--- + +## Installation + +1. **Upload** all files to your web server (e.g. `/live/` or wherever `/index.php` lives). + +2. **Set permissions** so PHP can write to these directories: + ```bash + mkdir -p data media/videos media/thumbs + chmod 755 data media media/videos media/thumbs + ``` + +3. **Visit** `index.php` — the SQLite database is created automatically on first load. + +4. **Log into admin** at `admin/login.php` + - Default password: **`admin`** + - ⚠️ Change it immediately in **Admin → Settings → Change Password** + +--- + +## File Structure + +``` +/ ← web root (deploy here) +├── index.php ← Main player + catalogue (home page) +├── catalogue.php ← Full video catalogue browse page +├── includes/ +│ ├── db.php ← SQLite bootstrap + helper functions +│ └── 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 +│ ├── settings.php ← Site settings, per-page count, password +│ ├── login.php ← Admin login +│ ├── logout.php ← Session destroy + redirect +│ └── auth.php ← Session auth helpers +├── media/ ← All media files (created automatically) +│ ├── videos/ ← Uploaded video files +│ └── thumbs/ ← Uploaded thumbnail images +└── data/ + └── media.db ← SQLite database (auto-created) +``` + +--- + +## Usage + +### Adding Videos (Admin) + +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: + - **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 + +### Catalogue Per-Page + +Configurable in **Admin → Settings → Videos per page** (1–50). +The home page catalogue and the full catalogue page both respect this setting. + +### Publishing + +Videos can be toggled between **Published** and **Draft** from the admin list. +Only published videos appear on the public-facing pages. + +### Sorting + +Use the **Sort Order** field — lower numbers appear first. Default is 0. + +--- + +## Player Features + +- **Video.js 8.x** — responsive, accessible HTML5 player +- 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 +- Thumbnail posters +- Keyboard accessible + +--- + +## Security Notes + +- Change the default admin password (`admin`) immediately after setup +- Consider adding `.htaccess` HTTP auth or IP restriction to `/admin/` +- The `data/` directory should not be web-accessible; add to `.htaccess`: + ```apache + + Require all denied + + ``` +- For production, consider adding CSRF tokens to admin forms + +--- + +## Customisation + +- **Branding**: Edit site title/subtitle in Admin → Settings +- **Theme colours**: All CSS variables are in `includes/layout.php` `:root {}` +- **Social links**: Edit the About/Links cards in `index.php` diff --git a/admin/add.php b/admin/add.php new file mode 100644 index 0000000..936b82d --- /dev/null +++ b/admin/add.php @@ -0,0 +1,307 @@ +prepare(" + INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published) + VALUES (?, ?, ?, ?, ?, ?, ?) + ")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published]); + + $video_id = (int)$db->lastInsertId(); + + // Handle video source files + $source_labels = $_POST['source_label'] ?? []; + $source_quality = $_POST['source_quality'] ?? []; + $source_order = $_POST['source_sort'] ?? []; + $source_files = $_FILES['source_file'] ?? []; + + $videos_dir = MEDIA_DIR . 'videos/'; + if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true); + + $inserted_sources = 0; + $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; + + $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++; + } + } + } + + header('Location: edit.php?id=' . $video_id . '&saved=1'); + exit; + } + } +} + +render_head('Add Video — Admin', ' + .form-grid { display:grid; grid-template-columns:1fr; gap:1.2rem; } + @media(min-width:700px) { .form-grid { grid-template-columns:1fr 1fr; } } + .form-grid .span2 { grid-column:1/-1; } + + .sources-section { display:flex; flex-direction:column; gap:.85rem; } + .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; + gap:.75rem; align-items:end; + } + @media(max-width:700px) { .source-row { grid-template-columns:1fr 1fr; } } + .add-source-btn { align-self:flex-start; } + + .section-divider { + border:none; border-top:1px solid var(--border); margin:1.5rem 0 .5rem; + } + .section-heading { + font-family:"Share Tech Mono",monospace; font-size:.65rem; + letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); + margin-bottom:.75rem; + } + .hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; } + + .admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; } + @media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } } + .admin-nav { + background:var(--surface); border:1px solid var(--border); + border-radius:calc(var(--r)+2px); padding:1.2rem; + display:flex; flex-direction:column; gap:.4rem; + align-self:start; position:sticky; top:1rem; + } + .admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; } + .admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; } + .admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); } + .admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); } + .nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; } +'); +?> +
+
+
+
+
Ty Clifford
+
admin / add video
+
+
+ +
+ + +
+ + +
+ + +
+
+
+

New Entry

+

Add Video

+
+ ← Back +
+ +
+ + +
+

Video Details

+
+
+ + +
+
+ + +
+
+ + + e.g. 3600 = 1 hour. Leave 0 if unknown. +
+
+ + + Lower numbers appear first. +
+
+ + + JPG, PNG, WebP or GIF. Shown as 16:9 preview. +
+
+ + Uncheck to save as draft. +
+
+
+ + +
+

Video Sources (Multiple Formats)

+

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

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + Cancel +
+ +
+
+
+ + +
+ + + + diff --git a/admin/auth.php b/admin/auth.php new file mode 100644 index 0000000..e59568f --- /dev/null +++ b/admin/auth.php @@ -0,0 +1,36 @@ +prepare("SELECT * FROM video_sources WHERE id=? AND video_id=?"); + $src->execute([$sid, $id]); + $s = $src->fetch(); + if ($s) { + $f = MEDIA_DIR . $s['file_path']; + if (file_exists($f)) unlink($f); + $db->prepare("DELETE FROM video_sources WHERE id=?")->execute([$sid]); + $msg = 'Source file removed.'; + } + $video = get_video_by_id($id); // refresh +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? 'save'; + + if ($action === 'save') { + $title = trim($_POST['title'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $duration = (int)($_POST['duration'] ?? 0); + $sort_order = (int)($_POST['sort_order'] ?? 0); + $published = isset($_POST['published']) ? 1 : 0; + + if (!$title) { + $error = 'Title is required.'; + } else { + $slug = make_slug($title, $id); + + // Handle thumbnail replacement + $thumbnail = $video['thumbnail']; + if (!empty($_FILES['thumbnail']['tmp_name'])) { + $ext = strtolower(pathinfo($_FILES['thumbnail']['name'], PATHINFO_EXTENSION)); + $allowed = ['jpg','jpeg','png','webp','gif']; + if (in_array($ext, $allowed)) { + $thumb_dir = MEDIA_DIR . 'thumbs/'; + if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true); + // Remove old thumb + if ($thumbnail && file_exists(MEDIA_DIR . $thumbnail)) unlink(MEDIA_DIR . $thumbnail); + $fname = $slug . '_thumb_' . time() . '.' . $ext; + if (move_uploaded_file($_FILES['thumbnail']['tmp_name'], $thumb_dir . $fname)) { + $thumbnail = 'thumbs/' . $fname; + } + } else { + $error = 'Thumbnail must be JPG, PNG, WebP, or GIF.'; + } + } + + if (!$error) { + $db->prepare(" + UPDATE videos SET title=?, description=?, slug=?, thumbnail=?, + duration=?, sort_order=?, published=?, + updated_at=datetime('now') + WHERE id=? + ")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published, $id]); + + // Update existing sources sort/label/quality + $upd_labels = $_POST['src_label'] ?? []; + $upd_quality = $_POST['src_quality'] ?? []; + $upd_sort = $_POST['src_sort'] ?? []; + $upd_ids = $_POST['src_id'] ?? []; + foreach ($upd_ids as $i => $sid) { + $db->prepare("UPDATE video_sources SET label=?, quality=?, sort_order=? WHERE id=? AND video_id=?") + ->execute([ + $upd_labels[$i] ?? '', + $upd_quality[$i] ?? '', + (int)($upd_sort[$i] ?? $i), + (int)$sid, $id + ]); + } + + // Add new source files + $new_labels = $_POST['new_source_label'] ?? []; + $new_quality = $_POST['new_source_quality'] ?? []; + $new_sort = $_POST['new_source_sort'] ?? []; + $new_files = $_FILES['new_source_file'] ?? []; + $videos_dir = MEDIA_DIR . 'videos/'; + if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true); + $allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi']; + + if (!empty($new_files['tmp_name'])) { + foreach ($new_files['tmp_name'] as $i => $tmp) { + if (empty($tmp) || !is_uploaded_file($tmp)) continue; + $orig = $new_files['name'][$i]; + $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowed_video)) continue; + $fname = $slug . '_' . time() . '_' . $i . '.' . $ext; + if (move_uploaded_file($tmp, $videos_dir . $fname)) { + $db->prepare("INSERT INTO video_sources (video_id,label,file_path,mime_type,quality,sort_order) VALUES (?,?,?,?,?,?)") + ->execute([$id, + trim($new_labels[$i] ?? ''), + 'videos/' . $fname, + mime_from_ext($fname), + trim($new_quality[$i] ?? '720p'), + (int)($new_sort[$i] ?? $i), + ]); + } + } + } + + header('Location: edit.php?id=' . $id . '&saved=1'); + exit; + } + } + } +} + +// Refresh +$video = get_video_by_id($id); + +render_head('Edit Video — Admin', ' + .form-grid { display:grid; grid-template-columns:1fr; gap:1.2rem; } + @media(min-width:700px) { .form-grid { grid-template-columns:1fr 1fr; } } + .form-grid .span2 { grid-column:1/-1; } + .section-heading { font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); margin-bottom:.75rem; } + .hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; } + + .source-list { display:flex; flex-direction:column; gap:.75rem; } + .source-item { + background:var(--surface-2); border:1px solid var(--border); + border-radius:var(--r); padding:.85rem 1rem; + } + .source-item-header { display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.75rem; flex-wrap:wrap; } + .source-item-file { font-family:"Share Tech Mono",monospace; font-size:.68rem; color:var(--text-2); word-break:break-all; } + .source-item-mime { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); } + .source-item-fields { display:grid; grid-template-columns:1fr 1fr 80px; gap:.6rem; } + @media(max-width:600px) { .source-item-fields { grid-template-columns:1fr 1fr; } } + + .add-source-row { background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r); padding:1rem; display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:.75rem; align-items:end; } + @media(max-width:700px) { .add-source-row { grid-template-columns:1fr 1fr; } } + + .admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; } + @media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } } + .admin-nav { background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); padding:1.2rem; display:flex; flex-direction:column; gap:.4rem; align-self:start; position:sticky; top:1rem; } + .admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; } + .admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; } + .admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); } + .nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; } + + .thumb-preview { width:100%; max-width:240px; aspect-ratio:16/9; object-fit:cover; border-radius:var(--r); border:1px solid var(--border); background:var(--surface-2); display:block; margin-bottom:.5rem; } +'); +?> +
+
+
+
+
Ty Clifford
+
admin / edit video
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+
+
+

Editing

+

+
+ ← Back +
+ +
+ + + +
+

Video Details

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Current thumbnail + Current: + + + Upload new image to replace current thumbnail. +
+
+
+ + + +
+

Existing Sources

+
+ $s): ?> +
+
+
+
+
+
+ + + Remove + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + + +
+

Add New Source Files

+

Upload additional formats (MP4, WebM, OGV, MOV, MKV, AVI).

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + Cancel +
+
+
+
+ + +
+ + + + diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..9c4e318 --- /dev/null +++ b/admin/index.php @@ -0,0 +1,295 @@ +prepare("DELETE FROM videos WHERE id=?")->execute([$id]); + $msg = 'Video deleted.'; + } + } + + if ($action === 'toggle_published' && !empty($_POST['id'])) { + $id = (int)$_POST['id']; + $row = $db->prepare("SELECT published FROM videos WHERE id=?"); + $row->execute([$id]); + $cur = $row->fetchColumn(); + $db->prepare("UPDATE videos SET published=? WHERE id=?")->execute([$cur ? 0 : 1, $id]); + $msg = 'Video ' . ($cur ? 'unpublished' : 'published') . '.'; + } +} + +// Search + pagination +$search = trim($_GET['q'] ?? ''); +$page = max(1, (int)($_GET['page'] ?? 1)); +$per_page = (int)setting('catalogue_per_page', '10'); + +$where_sql = $search ? "WHERE v.title LIKE :q OR v.description LIKE :q" : ''; +$bind = $search ? [':q' => '%' . $search . '%'] : []; + +$total_stmt = $db->prepare("SELECT COUNT(*) FROM videos v $where_sql"); +$total_stmt->execute($bind); +$total = (int)$total_stmt->fetchColumn(); +$total_pages = max(1, (int)ceil($total / $per_page)); +$page = min($page, $total_pages); +$offset = ($page - 1) * $per_page; + +$list_stmt = $db->prepare(" + SELECT v.id, v.title, v.slug, v.thumbnail, v.duration, v.published, v.sort_order, + v.created_at, + COUNT(vs.id) AS source_count + FROM videos v + LEFT JOIN video_sources vs ON vs.video_id = v.id + $where_sql + GROUP BY v.id + ORDER BY v.sort_order ASC, v.id DESC + LIMIT :limit OFFSET :offset +"); +foreach ($bind as $k => $val) $list_stmt->bindValue($k, $val); +$list_stmt->bindValue(':limit', $per_page, PDO::PARAM_INT); +$list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT); +$list_stmt->execute(); +$videos = $list_stmt->fetchAll(); + +$qs = fn($p) => '?' . http_build_query(array_filter(['q' => $search, 'page' => $p])); + +render_head('Admin — TyClifford.com', ' + .admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; } + @media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } } + + .admin-nav { + background:var(--surface); border:1px solid var(--border); + border-radius:calc(var(--r)+2px); padding:1.2rem; + display:flex; flex-direction:column; gap:.4rem; + align-self:start; position:sticky; top:1rem; + } + .admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; } + .admin-nav a { + display:flex; align-items:center; gap:.6rem; + padding:.55rem .75rem; border-radius:var(--r); + font-size:.82rem; color:var(--text-2); text-decoration:none; + transition:background .15s, color .15s; + } + .admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); } + .admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); } + .admin-nav a svg { flex-shrink:0; } + .nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; } + + .admin-main { display:flex; flex-direction:column; gap:1rem; } + .admin-toolbar { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; justify-content:space-between; } + .search-form { display:flex; gap:.5rem; flex:1; min-width:0; max-width:360px; } + .search-form input { flex:1; } + + .video-table { width:100%; border-collapse:collapse; } + .video-table th, .video-table td { + padding:.65rem .85rem; text-align:left; + border-bottom:1px solid var(--border); + font-size:.82rem; + } + .video-table th { + font-family:"Share Tech Mono",monospace; font-size:.62rem; letter-spacing:.12em; + text-transform:uppercase; color:var(--text-3); background:var(--surface-2); + } + .video-table tr:hover td { background:rgba(255,255,255,.02); } + .video-table .thumb-cell { width:80px; } + .video-table .thumb-cell img { width:72px; height:40px; object-fit:cover; border-radius:4px; display:block; background:var(--surface-2); } + .video-table .thumb-cell .no-thumb-sm { width:72px; height:40px; border-radius:4px; background:var(--surface-2); display:flex; align-items:center; justify-content:center; font-family:"Share Tech Mono",monospace; font-size:.45rem; color:var(--text-3); } + .video-table .title-cell { max-width:250px; } + .video-table .title-cell a { color:var(--text); text-decoration:none; font-weight:600; } + .video-table .title-cell a:hover { color:var(--purple); } + .video-table .title-cell .slug { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); display:block; margin-top:.15rem; } + .video-table .actions-cell { white-space:nowrap; } + .badge { display:inline-flex; align-items:center; padding:.18em .55em; border-radius:99px; font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.08em; } + .badge-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.25); } + .badge-gray { background:rgba(255,255,255,.05); color:var(--text-3); border:1px solid var(--border-2); } + + .table-wrap { overflow-x:auto; background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); } + + .admin-footer-bar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; } + .rec-count { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; } +'); +?> +
+
+
+
+
Ty Clifford
+
admin / dashboard
+
+
+ +
+ + +
+ + +
+ + + + + +
+ +
+
+ + + + ✕ Clear + +
+ + + Add Video + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThumbTitle / SlugDurationSourcesStatusAddedActions
+ Add one!' ?> +
+ + + +
NO IMG
+ +
+ + / + + + + file + + + + + + + +
+ + + Edit + +
+ + + +
+
+ + + +
+
+
+
+ + 1 || $total > 0): ?> + + + +
+
+ + +
+ + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..1e90005 --- /dev/null +++ b/admin/login.php @@ -0,0 +1,81 @@ + +
+
+
+
+
Ty Clifford
+
admin dashboard
+
+
+ ← Back to site +
+ +
+ +
+ + +
+ + diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..021547f --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,5 @@ +query("SELECT COUNT(*) FROM videos")->fetchColumn(); +$total_sources = $db->query("SELECT COUNT(*) FROM video_sources")->fetchColumn(); +$db_size = file_exists(DB_PATH) ? round(filesize(DB_PATH)/1024, 1) : 0; +$media_size = 0; +if (is_dir(MEDIA_DIR)) { + $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(MEDIA_DIR, FilesystemIterator::SKIP_DOTS)); + foreach ($iter as $f) $media_size += $f->getSize(); + $media_size = round($media_size / 1048576, 1); +} + +$msg_type = str_starts_with($msg, 'ERROR') ? 'error' : 'success'; + +render_head('Settings — Admin', ' + .admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; } + @media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } } + .admin-nav { background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); padding:1.2rem; display:flex; flex-direction:column; gap:.4rem; align-self:start; position:sticky; top:1rem; } + .admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; } + .admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; } + .admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); } + .admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); } + .nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; } + .form-grid { display:grid; grid-template-columns:1fr; gap:1rem; } + @media(min-width:600px) { .form-grid { grid-template-columns:1fr 1fr; } } + .section-heading { font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); margin-bottom:.75rem; } + .hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; } + .stat-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:.75rem; } + .stat-tile { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r); padding:1rem; display:flex; flex-direction:column; gap:.3rem; } + .stat-val { font-family:"Share Tech Mono",monospace; font-size:1.4rem; color:var(--text); font-weight:700; } + .stat-label{ font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.1em; text-transform:uppercase; color:var(--text-3); } +'); +?> +
+
+
+
+
Ty Clifford
+
admin / settings
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+

Overview

+
+
+ + Videos +
+
+ + Source Files +
+
+ KB + Database +
+
+ MB + Media Storage +
+
+
+ + +
+

General Settings

+
+ +
+
+ + +
+
+ + +
+
+ + + How many thumbnails to show per page (1–50). +
+
+ +
+
+ + +
+

Change Password

+
+ +
+
+ + +
+
+
+ + + Minimum 6 characters. +
+
+ + +
+
+ +
+
+ + +
+

File Locations

+
+
Database: data/media.db
+
Videos: media/videos/
+
Thumbnails: media/thumbs/
+
Default password: admin — change immediately!
+
+
+ +
+
+ + +
+ + diff --git a/catalogue.php b/catalogue.php new file mode 100644 index 0000000..676d5f2 --- /dev/null +++ b/catalogue.php @@ -0,0 +1,128 @@ + +
+ + + + + + +
+
+ +

no videos in the catalogue yet

+
+
+ +
+ + +
+ + <?= h($vtitle) ?> + +
NO THUMBNAIL
+ + + + +
+ +
+
+
+ + + + + +
+
+ +
+ + 1): ?> + + + + + +
+ + diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/embed.php b/embed.php new file mode 100644 index 0000000..2507e99 --- /dev/null +++ b/embed.php @@ -0,0 +1,246 @@ + on third-party sites. + * + * Usage: + */ +require_once __DIR__ . '/includes/db.php'; + +$slug = trim($_GET['v'] ?? ''); +$video = $slug ? get_video_by_slug($slug) : null; + +// Determine absolute base URL for media files. +// We need absolute URLs here because the embed lives on a foreign domain. +$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$script = dirname($_SERVER['SCRIPT_NAME'] ?? '/'); +$script = rtrim($script, '/'); +$base_url = $scheme . '://' . $host . $script . '/'; // e.g. https://tyclifford.com/live/ + +// X-Frame-Options: allow embedding from any origin. +// If you want to restrict, change ALLOWALL to SAMEORIGIN or remove for CSP header instead. +header('X-Frame-Options: ALLOWALL'); +header('Content-Security-Policy: frame-ancestors *'); +?> + + + + + + <?= $video ? htmlspecialchars($video['title'], ENT_QUOTES) . ' — TyClifford.com' : 'Video Player' ?> + + + + + + +
+
+ + + + +
+ + tyclifford.com +
+ + + + + +
+ + + + + +

+
+ + +
+ + + + + + + diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..183861b --- /dev/null +++ b/includes/db.php @@ -0,0 +1,168 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->exec('PRAGMA journal_mode=WAL'); + $pdo->exec('PRAGMA foreign_keys=ON'); + + // Schema + $pdo->exec(" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS videos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT 'Untitled', + description TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL UNIQUE, + thumbnail TEXT NOT NULL DEFAULT '', + duration INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + published INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS video_sources ( + 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, + mime_type TEXT NOT NULL DEFAULT 'video/mp4', + quality TEXT NOT NULL DEFAULT '720p', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + + 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_sources_video ON video_sources(video_id); + "); + + // Default settings + $defaults = [ + 'catalogue_per_page' => '10', + 'admin_password' => password_hash('admin', PASSWORD_DEFAULT), + 'site_title' => 'Ty Clifford', + 'site_sub' => 'tyclifford.com / media', + ]; + $ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)"); + foreach ($defaults as $k => $v) $ins->execute([$k, $v]); + + return $pdo; +} + +function setting(string $key, string $fallback = ''): string { + $row = get_db()->prepare("SELECT value FROM settings WHERE key=?"); + $row->execute([$key]); + $r = $row->fetch(); + return $r ? $r['value'] : $fallback; +} + +function set_setting(string $key, string $value): void { + get_db()->prepare("INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)") + ->execute([$key, $value]); +} + +function make_slug(string $title, int $id = 0): string { + $slug = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-')); + if ($id) { + $exists = get_db()->prepare("SELECT id FROM videos WHERE slug=? AND id!=?"); + $exists->execute([$slug, $id]); + } else { + $exists = get_db()->prepare("SELECT id FROM videos WHERE slug=?"); + $exists->execute([$slug]); + } + if ($exists->fetch()) $slug .= '-' . ($id ?: time()); + return $slug ?: 'video-' . time(); +} + +function mime_from_ext(string $path): string { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + return match($ext) { + 'mp4' => 'video/mp4', + 'webm' => 'video/webm', + 'ogv' => 'video/ogg', + 'mov' => 'video/quicktime', + 'mkv' => 'video/x-matroska', + 'avi' => 'video/x-msvideo', + default => 'video/mp4', + }; +} + +function format_duration(int $seconds): string { + if ($seconds <= 0) return ''; + $h = intdiv($seconds, 3600); + $m = intdiv($seconds % 3600, 60); + $s = $seconds % 60; + if ($h) return sprintf('%d:%02d:%02d', $h, $m, $s); + return sprintf('%d:%02d', $m, $s); +} + +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 { + $db = get_db(); + $where = $published_only ? 'WHERE v.published=1' : ''; + $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 + 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 ? + "); + $rows->execute([$per_page, $offset]); + return [ + 'videos' => $rows->fetchAll(), + 'total' => (int)$total, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages' => (int)ceil($total / $per_page), + ]; +} + +function get_video_by_slug(string $slug): ?array { + $db = get_db(); + $row = $db->prepare("SELECT * FROM videos WHERE slug=? AND published=1"); + $row->execute([$slug]); + $v = $row->fetch(); + if (!$v) return null; + $sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC"); + $sources->execute([$v['id']]); + $v['sources'] = $sources->fetchAll(); + return $v; +} + +function get_video_by_id(int $id): ?array { + $db = get_db(); + $row = $db->prepare("SELECT * FROM videos WHERE id=?"); + $row->execute([$id]); + $v = $row->fetch(); + if (!$v) return null; + $sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC"); + $sources->execute([$v['id']]); + $v['sources'] = $sources->fetchAll(); + return $v; +} diff --git a/includes/layout.php b/includes/layout.php new file mode 100644 index 0000000..51204ad --- /dev/null +++ b/includes/layout.php @@ -0,0 +1,276 @@ + + + + + + + <?= h($title) ?> + + + + + + + + + + +
+
+
+ +
+
+
+ +
+ + +'; + $prev = $current - 1; + $next = $current + 1; + if ($prev >= 1) { + echo ''; + } else { + echo ''; + } + $range = range(max(1, $current-2), min($total, $current+2)); + if (!in_array(1, $range)) { echo '1'; } + foreach ($range as $p) { + if ($p === $current) echo '' . $p . ''; + else echo '' . $p . ''; + } + if (!in_array($total, $range)) { echo '' . $total . ''; } + if ($next <= $total) { + echo ''; + } else { + echo ''; + } + echo ''; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..6e270cc --- /dev/null +++ b/index.php @@ -0,0 +1,452 @@ +query("SELECT slug FROM videos WHERE published=1 ORDER BY sort_order ASC, id DESC LIMIT 1")->fetch(); + if ($latest) $video = get_video_by_slug($latest['slug']); +} + +$per_page = (int)setting('catalogue_per_page', '10'); +$page = max(1, (int)($_GET['page'] ?? 1)); +$paged = get_videos_paginated($page, $per_page); + +// Build absolute base URL used for embed snippet generation +$_scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +$_host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$_script = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/'), '/'); +$_base_url = $_scheme . '://' . $_host . $_script . '/'; + +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-grid { + display:grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap:.85rem; + } + @media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } } + @media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(5, 1fr); } } + + .cat-thumb { + background:var(--surface-2); + border:1px solid var(--border); + border-radius:calc(var(--r)+2px); + overflow:hidden; + text-decoration:none; + color:var(--text); + display:flex; flex-direction:column; + transition:border-color .2s, transform .15s, box-shadow .2s; + position:relative; + cursor:pointer; + } + .cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); } + .cat-thumb.active { border-color:var(--green); box-shadow:0 0 0 1px var(--green), 0 4px 20px rgba(0,232,122,.2); } + .cat-thumb-img { + position:relative; width:100%; padding-top:56.25%; background:#000; + overflow:hidden; + } + .cat-thumb-img img { + position:absolute; inset:0; width:100%; height:100%; object-fit:cover; + transition:transform .3s; + } + .cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); } + .cat-thumb-img .no-thumb { + position:absolute; inset:0; display:flex; align-items:center; justify-content:center; + font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em; + background:var(--surface); + } + .cat-thumb-dur { + position:absolute; bottom:4px; right:6px; + background:rgba(0,0,0,.75); backdrop-filter:blur(4px); + font-family:"Share Tech Mono",monospace; font-size:.55rem; letter-spacing:.05em; + color:#fff; padding:.15em .45em; border-radius:3px; + } + .play-overlay { + position:absolute; inset:0; display:flex; align-items:center; justify-content:center; + background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s; + } + .cat-thumb:hover .play-overlay { opacity:1; } + .play-overlay svg { filter:drop-shadow(0 2px 8px rgba(0,0,0,.5)); } + .cat-thumb-body { padding:.55rem .65rem; display:flex; flex-direction:column; gap:.2rem; flex:1; } + .cat-thumb-title { font-size:.78rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } + .cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.58rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.35rem; } + .cat-active-indicator { + position:absolute; top:0; left:0; right:0; height:2px; + background:var(--green); opacity:0; transition:opacity .2s; + } + .cat-thumb.active .cat-active-indicator { opacity:1; } + + /* ── Video.js custom skin ── */ + .video-js { + width:100% !important; + height:100% !important; + position:absolute !important; + inset:0 !important; + } + .video-js .vjs-big-play-button { + background:rgba(176,96,255,.75) !important; + border:2px solid rgba(176,96,255,.9) !important; + border-radius:50% !important; + width:64px !important; height:64px !important; + top:50% !important; left:50% !important; + transform:translate(-50%,-50%) !important; + margin:0 !important; + line-height:64px !important; + transition:background .2s !important; + } + .video-js:hover .vjs-big-play-button { background:rgba(176,96,255,.95) !important; } + .video-js .vjs-control-bar { + background:linear-gradient(transparent,rgba(0,0,0,.85)) !important; + height:3.2em !important; + } + .video-js .vjs-play-progress { background:var(--green) !important; } + .video-js .vjs-volume-level { background:var(--green) !important; } + .video-js .vjs-slider { background:rgba(255,255,255,.15) !important; } + .video-js .vjs-load-progress { background:rgba(255,255,255,.1) !important; } + .video-js .vjs-current-time, + .video-js .vjs-duration, + .video-js .vjs-time-divider { color:rgba(255,255,255,.8) !important; font-size:.7em !important; } + + /* ── Lower grid ── */ + .lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; } + @media(min-width:640px) { .lower-grid { grid-template-columns:1fr 1fr; } } + @media(min-width:900px) { .lower-grid { grid-template-columns:2fr 1fr 1fr; } } + .card-about { background:var(--surface-2); } + .card-social { background:var(--surface-2); } + .social-list { list-style:none; display:flex; flex-direction:column; gap:.5rem; } + .social-list a { + display:flex; align-items:center; gap:.65rem; + padding:.6rem .8rem; border:1px solid var(--border-2); border-radius:var(--r); + font-family:"Share Tech Mono",monospace; font-size:.78rem; color:var(--text-2); + text-decoration:none; transition:border-color .2s, color .2s, background .2s; + } + .social-list a:hover { border-color:var(--green); color:var(--text); background:rgba(0,232,122,.04); } + .social-list a svg { flex-shrink:0; color:var(--text-3); transition:color .2s; } + .social-list a:hover svg { color:var(--green); } + + /* empty state */ + .empty-state { + padding:3rem 1rem; text-align:center; display:flex; flex-direction:column; + align-items:center; gap:1rem; + } + .empty-state svg { color:var(--text-3); opacity:.4; } + .empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; } + + /* no-video placeholder */ + .no-video { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:.75rem; } + .no-video p { font-family:"Share Tech Mono",monospace; font-size:.7rem; color:var(--text-3); letter-spacing:.1em; } + + /* pagination row */ + .cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; } + .cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; } + + /* ── Embed panel ── */ + .embed-panel { + border-top:1px solid var(--border); + overflow:hidden; + max-height:0; + transition:max-height .35s cubic-bezier(.4,0,.2,1), padding .35s; + padding:0 1.1rem; + } + .embed-panel.open { max-height:320px; padding:.9rem 1.1rem; } + .embed-panel-inner { display:flex; flex-direction:column; gap:.75rem; } + .embed-label { font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.14em; text-transform:uppercase; color:var(--text-3); } + .embed-code-wrap { position:relative; } + .embed-code { + display:block; width:100%; + background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r); + padding:.65rem .85rem; padding-right:5.5rem; + font-family:"Share Tech Mono",monospace; font-size:.68rem; color:var(--text-2); + white-space:nowrap; overflow-x:auto; line-height:1.6; + resize:none; cursor:text; + scrollbar-width:thin; + } + .embed-copy-btn { + position:absolute; right:.4rem; top:50%; transform:translateY(-50%); + padding:.32rem .7rem; font-size:.65rem; font-weight:600; + background:rgba(176,96,255,.15); color:var(--purple); + border:1px solid rgba(176,96,255,.35); border-radius:calc(var(--r) - 1px); + cursor:pointer; font-family:"Share Tech Mono",monospace; letter-spacing:.06em; + transition:background .2s, color .2s; + white-space:nowrap; + } + .embed-copy-btn:hover { background:rgba(176,96,255,.28); } + .embed-copy-btn.copied { background:rgba(0,232,122,.15); color:var(--green); border-color:rgba(0,232,122,.35); } + .embed-row { display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; } + .embed-direct-link { + font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); + text-decoration:none; border-bottom:1px solid rgba(255,255,255,.1); + transition:color .15s, border-color .15s; + } + .embed-direct-link:hover { color:var(--green); border-color:rgba(0,232,122,.4); } + .embed-preview-link { + font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--purple); + text-decoration:none; border-bottom:1px solid rgba(176,96,255,.2); + transition:color .15s; + } + .embed-preview-link:hover { color:#c880ff; } + .embed-toggle-btn { + display:inline-flex; align-items:center; gap:.4rem; + font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.08em; + color:var(--text-3); background:transparent; border:1px solid var(--border-2); + border-radius:var(--r); padding:.35rem .7rem; cursor:pointer; + transition:color .2s, border-color .2s, background .2s; + } + .embed-toggle-btn:hover { color:var(--text-2); border-color:var(--purple); background:rgba(176,96,255,.07); } + .embed-toggle-btn.active { color:var(--purple); border-color:var(--purple); background:rgba(176,96,255,.1); } +'); +?> +
+ + + + +
+
+ + + +
+ +

no video selected

+
+ +
+ + + + '; + ?> +
+
+
+ iframe embed code +
+ + +
+
+
+ Direct player URL — + + + + + ↗ Preview + +
+
+ + Recommended: width="560" height="315" — any 16:9 size works. Add style="border:0;border-radius:6px" for no border. + +
+
+
+ +
+ + +
+
+

Video Catalogue

+ Browse all → +
+ + +
+
+ +

no videos yet

+
+
+ + + + 1): ?> + + + +
+ + +
+ +
+

About

+

Ty Clifford

+

+ Based in Keyser, West Virginia. Nerd, tinkerer, and creator. + Hobbies are my hobby — from tech and podcasting to whatever rabbit hole I fell into this week. +

+
+ + + +
+ + +
+ + + + diff --git a/media/.DS_Store b/media/.DS_Store new file mode 100644 index 0000000..800bfce Binary files /dev/null and b/media/.DS_Store differ diff --git a/media/.gitkeep b/media/.gitkeep new file mode 100644 index 0000000..e69de29