- Done. The media platform now tracks sources as either local uploads or remote URLs.
Changed: [includes/db.php (line 43)](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php:43) adds source_type and source_url, with an automatic migration for existing SQLite databases. [admin/add.php (line 260)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/add.php:260) and [admin/edit.php (line 383)](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/edit.php:383) now let each source be an upload or an external URL. [index.php (line 226)](/Users/tyemeclifford/Documents/GH/mediaplayer/index.php:226) and [embed.php (line 193)](/Users/tyemeclifford/Documents/GH/mediaplayer/embed.php:193) resolve playback URLs based on where the source lives. [catalogue.php (line 79)](/Users/tyemeclifford/Documents/GH/mediaplayer/catalogue.php:79) now has public search for published videos only. [README.md (line 63)](/Users/tyemeclifford/Documents/GH/mediaplayer/README.md:63) documents local vs remote sources and public search.
This commit is contained in:
@@ -41,8 +41,8 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
|
|||||||
│ └── layout.php ← Shared HTML head, topbar, footer, pagination
|
│ └── layout.php ← Shared HTML head, topbar, footer, pagination
|
||||||
├── admin/
|
├── admin/
|
||||||
│ ├── index.php ← Video list with search + pagination
|
│ ├── index.php ← Video list with search + pagination
|
||||||
│ ├── add.php ← Add new video with multi-format sources
|
│ ├── add.php ← Add new video with uploaded or external URL sources
|
||||||
│ ├── edit.php ← Edit video metadata + manage sources
|
│ ├── edit.php ← Edit video metadata + manage local/remote sources
|
||||||
│ ├── settings.php ← Site settings, per-page count, password
|
│ ├── settings.php ← Site settings, per-page count, password
|
||||||
│ ├── login.php ← Admin login
|
│ ├── login.php ← Admin login
|
||||||
│ ├── logout.php ← Session destroy + redirect
|
│ ├── 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`
|
1. Go to `admin/add.php`
|
||||||
2. Fill in title, description, duration, sort order
|
2. Fill in title, description, duration, sort order
|
||||||
3. Upload a thumbnail (JPG/PNG/WebP)
|
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
|
- **MP4** (H.264) — widest compatibility
|
||||||
- **WebM** (VP9) — smaller size, modern browsers
|
- **WebM** (VP9) — smaller size, modern browsers
|
||||||
- **OGV** — Firefox fallback (legacy)
|
- **OGV** — Firefox fallback (legacy)
|
||||||
5. Set quality labels (1080p, 720p, 480p, etc.)
|
6. Set quality labels (1080p, 720p, 480p, etc.)
|
||||||
6. Save — Video.js will auto-select the best format the browser supports
|
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
|
### 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
|
- Custom skin matching the site's dark purple/green theme
|
||||||
- Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2×
|
- Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2×
|
||||||
- Multiple source fallback — browser picks best format automatically
|
- Multiple source fallback — browser picks best format automatically
|
||||||
|
- Local uploaded sources and remote URL sources
|
||||||
- Thumbnail posters
|
- Thumbnail posters
|
||||||
- Keyboard accessible
|
- Keyboard accessible
|
||||||
|
|
||||||
|
|||||||
+89
-24
@@ -45,42 +45,76 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
$video_id = (int)$db->lastInsertId();
|
$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_labels = $_POST['source_label'] ?? [];
|
||||||
$source_quality = $_POST['source_quality'] ?? [];
|
$source_quality = $_POST['source_quality'] ?? [];
|
||||||
$source_order = $_POST['source_sort'] ?? [];
|
$source_order = $_POST['source_sort'] ?? [];
|
||||||
|
$source_urls = $_POST['source_url'] ?? [];
|
||||||
$source_files = $_FILES['source_file'] ?? [];
|
$source_files = $_FILES['source_file'] ?? [];
|
||||||
|
|
||||||
$videos_dir = MEDIA_DIR . 'videos/';
|
$videos_dir = MEDIA_DIR . 'videos/';
|
||||||
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
|
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
|
||||||
|
|
||||||
$inserted_sources = 0;
|
$inserted_sources = 0;
|
||||||
|
$moved_files = [];
|
||||||
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
|
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
|
||||||
|
|
||||||
if (!empty($source_files['tmp_name'])) {
|
$source_count = max(
|
||||||
foreach ($source_files['tmp_name'] as $i => $tmp) {
|
count($source_types),
|
||||||
if (empty($tmp) || !is_uploaded_file($tmp)) continue;
|
count($source_labels),
|
||||||
$orig_name = $source_files['name'][$i];
|
count($source_urls),
|
||||||
$ext = strtolower(pathinfo($orig_name, PATHINFO_EXTENSION));
|
isset($source_files['tmp_name']) && is_array($source_files['tmp_name']) ? count($source_files['tmp_name']) : 0
|
||||||
if (!in_array($ext, $allowed_video)) continue;
|
);
|
||||||
|
|
||||||
$fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext;
|
for ($i = 0; $i < $source_count; $i++) {
|
||||||
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
|
$type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
|
||||||
$label = trim($source_labels[$i] ?? '');
|
$label = trim($source_labels[$i] ?? '');
|
||||||
$quality = trim($source_quality[$i] ?? '');
|
$quality = trim($source_quality[$i] ?? '');
|
||||||
$mime = mime_from_ext($fname);
|
$sort = (int)($source_order[$i] ?? $i);
|
||||||
$sort = (int)($source_order[$i] ?? $i);
|
|
||||||
$db->prepare("
|
if ($type === 'remote') {
|
||||||
INSERT INTO video_sources (video_id, label, file_path, mime_type, quality, sort_order)
|
$url = normalize_media_url($source_urls[$i] ?? '');
|
||||||
VALUES (?,?,?,?,?,?)
|
if ($url === '') {
|
||||||
")->execute([$video_id, $label, 'videos/' . $fname, $mime, $quality, $sort]);
|
if (trim($source_urls[$i] ?? '') !== '') $error = 'External video URLs must start with http:// or https://.';
|
||||||
$inserted_sources++;
|
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');
|
header('Location: edit.php?id=' . $video_id . '&saved=1');
|
||||||
exit;
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,11 +128,13 @@ render_head('Add Video — Admin', '
|
|||||||
.source-row {
|
.source-row {
|
||||||
background:var(--surface-2); border:1px solid var(--border);
|
background:var(--surface-2); border:1px solid var(--border);
|
||||||
border-radius:var(--r); padding:1rem;
|
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;
|
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; }
|
.add-source-btn { align-self:flex-start; }
|
||||||
|
.source-location-panel[hidden] { display:none; }
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
border:none; border-top:1px solid var(--border); margin:1.5rem 0 .5rem;
|
border:none; border-top:1px solid var(--border); margin:1.5rem 0 .5rem;
|
||||||
@@ -221,21 +257,32 @@ render_head('Add Video — Admin', '
|
|||||||
|
|
||||||
<!-- ── Video sources ── -->
|
<!-- ── Video sources ── -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="section-heading">Video Sources (Multiple Formats)</p>
|
<p class="section-heading">Video Sources</p>
|
||||||
<p class="hint" style="margin-bottom:1rem;">
|
<p class="hint" style="margin-bottom:1rem;">
|
||||||
Upload the same video in multiple formats/qualities. The browser will automatically pick the best one it can play.
|
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.
|
||||||
Accepted: <strong>MP4, WebM, OGV, MOV, MKV, AVI</strong>.
|
Uploaded files: <strong>MP4, WebM, OGV, MOV, MKV, AVI</strong>. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="sources-section" id="sources-container">
|
<div class="sources-section" id="sources-container">
|
||||||
<!-- Row template (JS clones this) -->
|
<!-- Row template (JS clones this) -->
|
||||||
<div class="source-row" data-source-row="0">
|
<div class="source-row" data-source-row="0">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source</label>
|
||||||
|
<select class="form-select source-type-select" name="source_type[]">
|
||||||
|
<option value="local" selected>Upload</option>
|
||||||
|
<option value="remote">External URL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group source-location-panel source-location-local">
|
||||||
<label class="form-label">Video File</label>
|
<label class="form-label">Video File</label>
|
||||||
<input class="form-input" type="file" name="source_file[]"
|
<input class="form-input" type="file" name="source_file[]"
|
||||||
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
|
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
|
||||||
style="padding:.45rem .6rem;">
|
style="padding:.45rem .6rem;">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group source-location-panel source-location-remote" hidden>
|
||||||
|
<label class="form-label">External URL</label>
|
||||||
|
<input class="form-input" type="url" name="source_url[]" placeholder="https://example.com/video.mp4">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Quality Label</label>
|
<label class="form-label">Quality Label</label>
|
||||||
<select class="form-select" name="source_quality[]">
|
<select class="form-select" name="source_quality[]">
|
||||||
@@ -260,7 +307,7 @@ render_head('Add Video — Admin', '
|
|||||||
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm add-source-btn" id="add-source" style="margin-top:.75rem;align-self:flex-start;">
|
<button type="button" class="btn btn-secondary btn-sm add-source-btn" id="add-source" style="margin-top:.75rem;align-self:flex-start;">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
Add Another Format
|
Add Another Source
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,6 +327,19 @@ render_head('Add Video — Admin', '
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function toggleSourceRow(row) {
|
||||||
|
var select = row.querySelector('.source-type-select');
|
||||||
|
var isRemote = select && select.value === 'remote';
|
||||||
|
row.querySelectorAll('.source-location-local').forEach(function(el){ el.hidden = isRemote; });
|
||||||
|
row.querySelectorAll('.source-location-remote').forEach(function(el){ el.hidden = !isRemote; });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-source-row]').forEach(function(row) {
|
||||||
|
toggleSourceRow(row);
|
||||||
|
var select = row.querySelector('.source-type-select');
|
||||||
|
if (select) select.addEventListener('change', function(){ toggleSourceRow(row); });
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('add-source').addEventListener('click', function() {
|
document.getElementById('add-source').addEventListener('click', function() {
|
||||||
var container = document.getElementById('sources-container');
|
var container = document.getElementById('sources-container');
|
||||||
var rows = container.querySelectorAll('[data-source-row]');
|
var rows = container.querySelectorAll('[data-source-row]');
|
||||||
@@ -289,7 +349,9 @@ document.getElementById('add-source').addEventListener('click', function() {
|
|||||||
// Clear file inputs (can't clone values for file inputs)
|
// Clear file inputs (can't clone values for file inputs)
|
||||||
first.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
|
first.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
|
||||||
first.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
|
first.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
|
||||||
|
first.querySelectorAll('input[type="url"]').forEach(function(el){ el.value=''; });
|
||||||
first.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
|
first.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
|
||||||
|
first.querySelectorAll('.source-type-select').forEach(function(el){ el.value='local'; });
|
||||||
// Add remove button if not already there
|
// Add remove button if not already there
|
||||||
if (!first.querySelector('.remove-row')) {
|
if (!first.querySelector('.remove-row')) {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
@@ -300,6 +362,9 @@ document.getElementById('add-source').addEventListener('click', function() {
|
|||||||
btn.onclick=function(){ this.closest('[data-source-row]').remove(); };
|
btn.onclick=function(){ this.closest('[data-source-row]').remove(); };
|
||||||
first.appendChild(btn);
|
first.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
toggleSourceRow(first);
|
||||||
|
var select = first.querySelector('.source-type-select');
|
||||||
|
if (select) select.addEventListener('change', function(){ toggleSourceRow(first); });
|
||||||
container.appendChild(first);
|
container.appendChild(first);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+158
-48
@@ -24,10 +24,12 @@ if (isset($_GET['del_src'])) {
|
|||||||
$src->execute([$sid, $id]);
|
$src->execute([$sid, $id]);
|
||||||
$s = $src->fetch();
|
$s = $src->fetch();
|
||||||
if ($s) {
|
if ($s) {
|
||||||
$f = MEDIA_DIR . $s['file_path'];
|
if (source_is_local($s)) {
|
||||||
if (file_exists($f)) unlink($f);
|
$f = MEDIA_DIR . $s['file_path'];
|
||||||
|
if ($s['file_path'] && file_exists($f)) unlink($f);
|
||||||
|
}
|
||||||
$db->prepare("DELETE FROM video_sources WHERE id=?")->execute([$sid]);
|
$db->prepare("DELETE FROM video_sources WHERE id=?")->execute([$sid]);
|
||||||
$msg = 'Source file removed.';
|
$msg = 'Source removed.';
|
||||||
}
|
}
|
||||||
$video = get_video_by_id($id); // refresh
|
$video = get_video_by_id($id); // refresh
|
||||||
}
|
}
|
||||||
@@ -78,48 +80,108 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$upd_labels = $_POST['src_label'] ?? [];
|
$upd_labels = $_POST['src_label'] ?? [];
|
||||||
$upd_quality = $_POST['src_quality'] ?? [];
|
$upd_quality = $_POST['src_quality'] ?? [];
|
||||||
$upd_sort = $_POST['src_sort'] ?? [];
|
$upd_sort = $_POST['src_sort'] ?? [];
|
||||||
|
$upd_urls = $_POST['src_url'] ?? [];
|
||||||
|
$upd_types = $_POST['src_type'] ?? [];
|
||||||
$upd_ids = $_POST['src_id'] ?? [];
|
$upd_ids = $_POST['src_id'] ?? [];
|
||||||
foreach ($upd_ids as $i => $sid) {
|
foreach ($upd_ids as $i => $sid) {
|
||||||
$db->prepare("UPDATE video_sources SET label=?, quality=?, sort_order=? WHERE id=? AND video_id=?")
|
$source_type = ($upd_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
|
||||||
->execute([
|
$source_url = '';
|
||||||
$upd_labels[$i] ?? '',
|
if ($source_type === 'remote') {
|
||||||
$upd_quality[$i] ?? '',
|
$source_url = normalize_media_url($upd_urls[$i] ?? '');
|
||||||
(int)($upd_sort[$i] ?? $i),
|
if ($source_url === '') {
|
||||||
(int)$sid, $id
|
$error = 'External video URLs must start with http:// or https://.';
|
||||||
]);
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
$upd_labels[$i] ?? '',
|
||||||
|
$upd_quality[$i] ?? '',
|
||||||
|
(int)($upd_sort[$i] ?? $i),
|
||||||
|
];
|
||||||
|
$sql = "UPDATE video_sources SET label=?, quality=?, sort_order=?";
|
||||||
|
if ($source_type === 'remote') {
|
||||||
|
$sql .= ", source_url=?, mime_type=?";
|
||||||
|
$params[] = $source_url;
|
||||||
|
$params[] = mime_from_ext($source_url);
|
||||||
|
}
|
||||||
|
$sql .= " WHERE id=? AND video_id=?";
|
||||||
|
$params[] = (int)$sid;
|
||||||
|
$params[] = $id;
|
||||||
|
|
||||||
|
$db->prepare($sql)->execute($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Location: edit.php?id=' . $id . '&saved=1');
|
// Add new sources
|
||||||
exit;
|
if (!$error) {
|
||||||
|
$new_types = $_POST['new_source_type'] ?? [];
|
||||||
|
$new_labels = $_POST['new_source_label'] ?? [];
|
||||||
|
$new_quality = $_POST['new_source_quality'] ?? [];
|
||||||
|
$new_sort = $_POST['new_source_sort'] ?? [];
|
||||||
|
$new_urls = $_POST['new_source_url'] ?? [];
|
||||||
|
$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'];
|
||||||
|
|
||||||
|
$new_count = max(
|
||||||
|
count($new_types),
|
||||||
|
count($new_labels),
|
||||||
|
count($new_urls),
|
||||||
|
isset($new_files['tmp_name']) && is_array($new_files['tmp_name']) ? count($new_files['tmp_name']) : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
$moved_files = [];
|
||||||
|
$inserted_source_ids = [];
|
||||||
|
for ($i = 0; $i < $new_count; $i++) {
|
||||||
|
$type = ($new_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
|
||||||
|
$label = trim($new_labels[$i] ?? '');
|
||||||
|
$quality = trim($new_quality[$i] ?? '720p');
|
||||||
|
$sort = (int)($new_sort[$i] ?? $i);
|
||||||
|
|
||||||
|
if ($type === 'remote') {
|
||||||
|
$url = normalize_media_url($new_urls[$i] ?? '');
|
||||||
|
if ($url === '') {
|
||||||
|
if (trim($new_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([$id, $label, 'remote', '', $url, mime_from_ext($url), $quality, $sort]);
|
||||||
|
$inserted_source_ids[] = (int)$db->lastInsertId();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmp = $new_files['tmp_name'][$i] ?? '';
|
||||||
|
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, true)) continue;
|
||||||
|
$fname = $slug . '_' . time() . '_' . $i . '.' . $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([$id, $label, 'local', 'videos/' . $fname, '', mime_from_ext($fname), $quality, $sort]);
|
||||||
|
$inserted_source_ids[] = (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
foreach ($inserted_source_ids as $inserted_id) {
|
||||||
|
$db->prepare("DELETE FROM video_sources WHERE id=? AND video_id=?")->execute([$inserted_id, $id]);
|
||||||
|
}
|
||||||
|
foreach ($moved_files as $moved) {
|
||||||
|
if (file_exists($moved)) unlink($moved);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
header('Location: edit.php?id=' . $id . '&saved=1');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,11 +205,18 @@ render_head('Edit Video — Admin', '
|
|||||||
.source-item-header { display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.75rem; flex-wrap:wrap; }
|
.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-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-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; }
|
.source-item-fields { display:grid; grid-template-columns:1.5fr 1fr 1fr 80px; gap:.6rem; }
|
||||||
@media(max-width:600px) { .source-item-fields { grid-template-columns:1fr 1fr; } }
|
.source-item-fields .span4 { grid-column:1/-1; }
|
||||||
|
@media(max-width:760px) { .source-item-fields { grid-template-columns:1fr 1fr; } }
|
||||||
|
@media(max-width:560px) { .source-item-fields { grid-template-columns:1fr; } }
|
||||||
|
.source-badge { display:inline-flex; align-items:center; padding:.16em .5em; border-radius:99px; font-family:"Share Tech Mono",monospace; font-size:.56rem; letter-spacing:.08em; text-transform:uppercase; border:1px solid var(--border-2); color:var(--text-3); margin-bottom:.25rem; }
|
||||||
|
.source-badge-remote { color:var(--blue); border-color:rgba(0,200,255,.35); background:rgba(0,200,255,.08); }
|
||||||
|
.source-badge-local { color:var(--green); border-color:rgba(0,232,122,.3); background:rgba(0,232,122,.08); }
|
||||||
|
|
||||||
.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; }
|
.add-source-row { background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r); padding:1rem; display:grid; grid-template-columns:140px minmax(220px,1.5fr) 1fr 1fr 80px auto; gap:.75rem; align-items:end; }
|
||||||
@media(max-width:700px) { .add-source-row { grid-template-columns:1fr 1fr; } }
|
@media(max-width:900px) { .add-source-row { grid-template-columns:1fr 1fr; } }
|
||||||
|
@media(max-width:560px) { .add-source-row { grid-template-columns:1fr; } }
|
||||||
|
.source-location-panel[hidden] { display:none; }
|
||||||
|
|
||||||
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
|
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
|
||||||
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
|
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
|
||||||
@@ -270,18 +339,30 @@ render_head('Edit Video — Admin', '
|
|||||||
<div class="source-item">
|
<div class="source-item">
|
||||||
<div class="source-item-header">
|
<div class="source-item-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="source-item-file"><?= h($s['file_path']) ?></div>
|
<span class="source-badge <?= source_is_remote($s) ? 'source-badge-remote' : 'source-badge-local' ?>">
|
||||||
|
<?= source_is_remote($s) ? 'External URL' : 'Uploaded File' ?>
|
||||||
|
</span>
|
||||||
|
<div class="source-item-file"><?= h(source_location($s)) ?></div>
|
||||||
<div class="source-item-mime"><?= h($s['mime_type']) ?></div>
|
<div class="source-item-mime"><?= h($s['mime_type']) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<a href="edit.php?id=<?= $id ?>&del_src=<?= $s['id'] ?>"
|
<a href="edit.php?id=<?= $id ?>&del_src=<?= $s['id'] ?>"
|
||||||
class="btn btn-danger btn-sm"
|
class="btn btn-danger btn-sm"
|
||||||
onclick="return confirm('Remove this source file from disk?')">
|
onclick="return confirm('Remove this source?<?= source_is_local($s) ? ' Uploaded files are also deleted from disk.' : '' ?>')">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
Remove
|
Remove
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="source-item-fields">
|
<div class="source-item-fields">
|
||||||
<input type="hidden" name="src_id[]" value="<?= $s['id'] ?>">
|
<input type="hidden" name="src_id[]" value="<?= $s['id'] ?>">
|
||||||
|
<input type="hidden" name="src_type[]" value="<?= h($s['source_type'] ?? 'local') ?>">
|
||||||
|
<?php if (source_is_remote($s)): ?>
|
||||||
|
<div class="form-group span4">
|
||||||
|
<label class="form-label">External URL</label>
|
||||||
|
<input class="form-input" type="url" name="src_url[]" value="<?= h($s['source_url']) ?>">
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<input type="hidden" name="src_url[]" value="">
|
||||||
|
<?php endif; ?>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Quality</label>
|
<label class="form-label">Quality</label>
|
||||||
<select class="form-select" name="src_quality[]">
|
<select class="form-select" name="src_quality[]">
|
||||||
@@ -307,16 +388,27 @@ render_head('Edit Video — Admin', '
|
|||||||
|
|
||||||
<!-- ── Add new sources ── -->
|
<!-- ── Add new sources ── -->
|
||||||
<div class="card" style="margin-bottom:1rem;">
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
<p class="section-heading">Add New Source Files</p>
|
<p class="section-heading">Add New Sources</p>
|
||||||
<p class="hint" style="margin-bottom:.85rem;">Upload additional formats (MP4, WebM, OGV, MOV, MKV, AVI).</p>
|
<p class="hint" style="margin-bottom:.85rem;">Upload another file or link an external URL. Remote URLs must use http:// or https://.</p>
|
||||||
<div class="source-list" id="new-sources-container">
|
<div class="source-list" id="new-sources-container">
|
||||||
<div class="add-source-row" data-new-row="0">
|
<div class="add-source-row" data-new-row="0">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source</label>
|
||||||
|
<select class="form-select source-type-select" name="new_source_type[]">
|
||||||
|
<option value="local" selected>Upload</option>
|
||||||
|
<option value="remote">External URL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group source-location-panel source-location-local">
|
||||||
<label class="form-label">Video File</label>
|
<label class="form-label">Video File</label>
|
||||||
<input class="form-input" type="file" name="new_source_file[]"
|
<input class="form-input" type="file" name="new_source_file[]"
|
||||||
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
|
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
|
||||||
style="padding:.45rem .6rem;">
|
style="padding:.45rem .6rem;">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group source-location-panel source-location-remote" hidden>
|
||||||
|
<label class="form-label">External URL</label>
|
||||||
|
<input class="form-input" type="url" name="new_source_url[]" placeholder="https://example.com/video.mp4">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Quality</label>
|
<label class="form-label">Quality</label>
|
||||||
<select class="form-select" name="new_source_quality[]">
|
<select class="form-select" name="new_source_quality[]">
|
||||||
@@ -340,7 +432,7 @@ render_head('Edit Video — Admin', '
|
|||||||
</div>
|
</div>
|
||||||
<button type="button" id="add-new-source" class="btn btn-secondary btn-sm" style="margin-top:.75rem;align-self:flex-start;">
|
<button type="button" id="add-new-source" class="btn btn-secondary btn-sm" style="margin-top:.75rem;align-self:flex-start;">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
Add Another Format
|
Add Another Source
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -359,6 +451,19 @@ render_head('Edit Video — Admin', '
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function toggleSourceRow(row) {
|
||||||
|
var select = row.querySelector('.source-type-select');
|
||||||
|
var isRemote = select && select.value === 'remote';
|
||||||
|
row.querySelectorAll('.source-location-local').forEach(function(el){ el.hidden = isRemote; });
|
||||||
|
row.querySelectorAll('.source-location-remote').forEach(function(el){ el.hidden = !isRemote; });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-new-row]').forEach(function(row) {
|
||||||
|
toggleSourceRow(row);
|
||||||
|
var select = row.querySelector('.source-type-select');
|
||||||
|
if (select) select.addEventListener('change', function(){ toggleSourceRow(row); });
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('add-new-source').addEventListener('click', function() {
|
document.getElementById('add-new-source').addEventListener('click', function() {
|
||||||
var container = document.getElementById('new-sources-container');
|
var container = document.getElementById('new-sources-container');
|
||||||
var rows = container.querySelectorAll('[data-new-row]');
|
var rows = container.querySelectorAll('[data-new-row]');
|
||||||
@@ -367,13 +472,18 @@ document.getElementById('add-new-source').addEventListener('click', function() {
|
|||||||
clone.setAttribute('data-new-row', idx);
|
clone.setAttribute('data-new-row', idx);
|
||||||
clone.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
|
clone.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
|
||||||
clone.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
|
clone.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
|
||||||
|
clone.querySelectorAll('input[type="url"]').forEach(function(el){ el.value=''; });
|
||||||
clone.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
|
clone.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
|
||||||
|
clone.querySelectorAll('.source-type-select').forEach(function(el){ el.value='local'; });
|
||||||
if (!clone.querySelector('.remove-row')) {
|
if (!clone.querySelector('.remove-row')) {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.type='button'; btn.className='btn btn-danger btn-sm remove-row'; btn.style='align-self:flex-end'; btn.innerHTML='✕';
|
btn.type='button'; btn.className='btn btn-danger btn-sm remove-row'; btn.style='align-self:flex-end'; btn.innerHTML='✕';
|
||||||
btn.onclick=function(){ this.closest('[data-new-row]').remove(); };
|
btn.onclick=function(){ this.closest('[data-new-row]').remove(); };
|
||||||
clone.appendChild(btn);
|
clone.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
toggleSourceRow(clone);
|
||||||
|
var select = clone.querySelector('.source-type-select');
|
||||||
|
if (select) select.addEventListener('change', function(){ toggleSourceRow(clone); });
|
||||||
container.appendChild(clone);
|
container.appendChild(clone);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+11
-5
@@ -15,10 +15,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$id = (int)$_POST['id'];
|
$id = (int)$_POST['id'];
|
||||||
$v = get_video_by_id($id);
|
$v = get_video_by_id($id);
|
||||||
if ($v) {
|
if ($v) {
|
||||||
// Remove source files + thumbnail
|
// Remove local source files + thumbnail
|
||||||
foreach ($v['sources'] as $s) {
|
foreach ($v['sources'] as $s) {
|
||||||
$f = MEDIA_DIR . $s['file_path'];
|
if (source_is_local($s)) {
|
||||||
if (file_exists($f)) unlink($f);
|
$f = MEDIA_DIR . $s['file_path'];
|
||||||
|
if ($s['file_path'] && file_exists($f)) unlink($f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($v['thumbnail']) {
|
if ($v['thumbnail']) {
|
||||||
$t = MEDIA_DIR . $v['thumbnail'];
|
$t = MEDIA_DIR . $v['thumbnail'];
|
||||||
@@ -57,7 +59,8 @@ $offset = ($page - 1) * $per_page;
|
|||||||
$list_stmt = $db->prepare("
|
$list_stmt = $db->prepare("
|
||||||
SELECT v.id, v.title, v.slug, v.thumbnail, v.duration, v.published, v.sort_order,
|
SELECT v.id, v.title, v.slug, v.thumbnail, v.duration, v.published, v.sort_order,
|
||||||
v.created_at,
|
v.created_at,
|
||||||
COUNT(vs.id) AS source_count
|
COUNT(vs.id) AS source_count,
|
||||||
|
SUM(CASE WHEN vs.source_type='remote' THEN 1 ELSE 0 END) AS remote_count
|
||||||
FROM videos v
|
FROM videos v
|
||||||
LEFT JOIN video_sources vs ON vs.video_id = v.id
|
LEFT JOIN video_sources vs ON vs.video_id = v.id
|
||||||
$where_sql
|
$where_sql
|
||||||
@@ -230,7 +233,10 @@ render_head('Admin — TyClifford.com', '
|
|||||||
<?= $v['duration'] ? format_duration((int)$v['duration']) : '—' ?>
|
<?= $v['duration'] ? format_duration((int)$v['duration']) : '—' ?>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-2);">
|
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-2);">
|
||||||
<?= $v['source_count'] ?> file<?= $v['source_count'] != 1 ? 's' : '' ?>
|
<?= $v['source_count'] ?> source<?= $v['source_count'] != 1 ? 's' : '' ?>
|
||||||
|
<?php if ((int)$v['remote_count'] > 0): ?>
|
||||||
|
<span style="color:var(--text-3);"> · <?= (int)$v['remote_count'] ?> URL<?= (int)$v['remote_count'] != 1 ? 's' : '' ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
|
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ render_head('Settings — Admin', '
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<span class="stat-val"><?= $total_sources ?></span>
|
<span class="stat-val"><?= $total_sources ?></span>
|
||||||
<span class="stat-label">Source Files</span>
|
<span class="stat-label">Sources</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<span class="stat-val"><?= $db_size ?> KB</span>
|
<span class="stat-val"><?= $db_size ?> KB</span>
|
||||||
|
|||||||
+20
-4
@@ -4,7 +4,8 @@ require_once __DIR__ . '/includes/layout.php';
|
|||||||
|
|
||||||
$per_page = (int)setting('catalogue_per_page', '10');
|
$per_page = (int)setting('catalogue_per_page', '10');
|
||||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
$paged = get_videos_paginated($page, $per_page);
|
$search = trim($_GET['q'] ?? '');
|
||||||
|
$paged = get_videos_paginated($page, $per_page, true, $search);
|
||||||
|
|
||||||
render_head('Videos — TyClifford.com', '
|
render_head('Videos — TyClifford.com', '
|
||||||
.cat-grid {
|
.cat-grid {
|
||||||
@@ -49,6 +50,9 @@ render_head('Videos — TyClifford.com', '
|
|||||||
|
|
||||||
.page-header { display:flex; flex-direction:column; gap:.5rem; }
|
.page-header { display:flex; flex-direction:column; gap:.5rem; }
|
||||||
.page-header-row { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
.page-header-row { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||||||
|
.public-search { display:flex; gap:.5rem; width:100%; max-width:460px; }
|
||||||
|
.public-search input { flex:1; min-width:0; }
|
||||||
|
@media(max-width:560px) { .public-search { max-width:none; } }
|
||||||
|
|
||||||
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-top:.5rem; }
|
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-top:.5rem; }
|
||||||
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
|
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
|
||||||
@@ -68,15 +72,27 @@ render_head('Videos — TyClifford.com', '
|
|||||||
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Library</p>
|
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Library</p>
|
||||||
<h1 style="font-size:1.1rem;font-weight:700;color:var(--text);">Video Catalogue</h1>
|
<h1 style="font-size:1.1rem;font-weight:700;color:var(--text);">Video Catalogue</h1>
|
||||||
</div>
|
</div>
|
||||||
<span class="cat-count"><?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?></span>
|
<span class="cat-count">
|
||||||
|
<?= $paged['total'] ?> <?= $search ? 'result' : 'video' ?><?= $paged['total'] != 1 ? 's' : '' ?>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<form class="public-search" method="get" action="catalogue.php" role="search">
|
||||||
|
<input class="form-input" type="search" name="q" value="<?= h($search) ?>" placeholder="Search public videos…">
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<?php if ($search): ?>
|
||||||
|
<a href="catalogue.php" class="btn btn-secondary btn-sm">Clear</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($paged['videos'])): ?>
|
<?php if (empty($paged['videos'])): ?>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
<p>no videos in the catalogue yet</p>
|
<p><?= $search ? 'no public videos matched your search' : 'no videos in the catalogue yet' ?></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -117,7 +133,7 @@ render_head('Videos — TyClifford.com', '
|
|||||||
<?php if ($paged['total_pages'] > 1): ?>
|
<?php if ($paged['total_pages'] > 1): ?>
|
||||||
<div class="cat-footer">
|
<div class="cat-footer">
|
||||||
<span class="cat-count">page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?></span>
|
<span class="cat-count">page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?></span>
|
||||||
<?php render_pagination($page, $paged['total_pages'], 'catalogue.php?page=%d'); ?>
|
<?php render_pagination($paged['page'], $paged['total_pages'], 'catalogue.php?' . ($search ? 'q=' . urlencode($search) . '&' : '') . 'page=%d'); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ header('Content-Security-Policy: frame-ancestors *');
|
|||||||
<?php endif; ?>>
|
<?php endif; ?>>
|
||||||
<?php foreach ($video['sources'] as $src): ?>
|
<?php foreach ($video['sources'] as $src): ?>
|
||||||
<source
|
<source
|
||||||
src="<?= htmlspecialchars($base_url . MEDIA_URL . $src['file_path'], ENT_QUOTES) ?>"
|
src="<?= htmlspecialchars(source_public_url($src, $base_url . MEDIA_URL), ENT_QUOTES) ?>"
|
||||||
type="<?= htmlspecialchars($src['mime_type'], ENT_QUOTES) ?>">
|
type="<?= htmlspecialchars($src['mime_type'], ENT_QUOTES) ?>">
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<p class="vjs-no-js">Your browser does not support HTML5 video.</p>
|
<p class="vjs-no-js">Your browser does not support HTML5 video.</p>
|
||||||
|
|||||||
+85
-8
@@ -44,7 +44,9 @@ function get_db(): PDO {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
|
||||||
label TEXT NOT NULL DEFAULT '',
|
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',
|
mime_type TEXT NOT NULL DEFAULT 'video/mp4',
|
||||||
quality TEXT NOT NULL DEFAULT '720p',
|
quality TEXT NOT NULL DEFAULT '720p',
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0
|
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_published ON videos(published);
|
||||||
CREATE INDEX IF NOT EXISTS idx_videos_sort ON videos(sort_order, id);
|
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);
|
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
|
// Default settings
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'catalogue_per_page' => '10',
|
'catalogue_per_page' => '10',
|
||||||
@@ -68,6 +74,20 @@ function get_db(): PDO {
|
|||||||
return $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 {
|
function setting(string $key, string $fallback = ''): string {
|
||||||
$row = get_db()->prepare("SELECT value FROM settings WHERE key=?");
|
$row = get_db()->prepare("SELECT value FROM settings WHERE key=?");
|
||||||
$row->execute([$key]);
|
$row->execute([$key]);
|
||||||
@@ -94,18 +114,55 @@ function make_slug(string $title, int $id = 0): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mime_from_ext(string $path): 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));
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
return match($ext) {
|
return match($ext) {
|
||||||
'mp4' => 'video/mp4',
|
'mp4' => 'video/mp4',
|
||||||
|
'm4v' => 'video/mp4',
|
||||||
'webm' => 'video/webm',
|
'webm' => 'video/webm',
|
||||||
'ogv' => 'video/ogg',
|
'ogv' => 'video/ogg',
|
||||||
|
'ogg' => 'video/ogg',
|
||||||
'mov' => 'video/quicktime',
|
'mov' => 'video/quicktime',
|
||||||
'mkv' => 'video/x-matroska',
|
'mkv' => 'video/x-matroska',
|
||||||
'avi' => 'video/x-msvideo',
|
'avi' => 'video/x-msvideo',
|
||||||
|
'm3u8' => 'application/vnd.apple.mpegurl',
|
||||||
|
'mpd' => 'application/dash+xml',
|
||||||
default => 'video/mp4',
|
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 {
|
function format_duration(int $seconds): string {
|
||||||
if ($seconds <= 0) return '';
|
if ($seconds <= 0) return '';
|
||||||
$h = intdiv($seconds, 3600);
|
$h = intdiv($seconds, 3600);
|
||||||
@@ -119,27 +176,47 @@ function h(string $s): string {
|
|||||||
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
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();
|
$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;
|
$offset = ($page - 1) * $per_page;
|
||||||
$total = $db->query("SELECT COUNT(*) FROM videos v $where")->fetchColumn();
|
|
||||||
$rows = $db->prepare("
|
$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
|
FROM videos v
|
||||||
LEFT JOIN video_sources vs ON vs.video_id = v.id
|
LEFT JOIN video_sources vs ON vs.video_id = v.id
|
||||||
$where
|
$where
|
||||||
GROUP BY v.id
|
GROUP BY v.id
|
||||||
ORDER BY v.sort_order ASC, v.id DESC
|
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 [
|
return [
|
||||||
'videos' => $rows->fetchAll(),
|
'videos' => $rows->fetchAll(),
|
||||||
'total' => (int)$total,
|
'total' => (int)$total,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'per_page' => $per_page,
|
'per_page' => $per_page,
|
||||||
'total_pages' => (int)ceil($total / $per_page),
|
'total_pages' => $total_pages,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ render_head('Media — TyClifford.com', '
|
|||||||
/* ── Catalogue grid ── */
|
/* ── Catalogue grid ── */
|
||||||
.cat-section { display:flex; flex-direction:column; gap:1rem; }
|
.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-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 {
|
.cat-grid {
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
@@ -220,7 +223,7 @@ render_head('Media — TyClifford.com', '
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
|
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
|
||||||
<?php foreach ($video['sources'] as $src): ?>
|
<?php foreach ($video['sources'] as $src): ?>
|
||||||
<source src="<?= h(MEDIA_URL . $src['file_path']) ?>" type="<?= h($src['mime_type']) ?>" label="<?= h($src['quality'] ?: $src['label']) ?>">
|
<source src="<?= h(source_public_url($src)) ?>" type="<?= h($src['mime_type']) ?>" label="<?= h($src['quality'] ?: $src['label']) ?>">
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<p class="vjs-no-js">Your browser does not support HTML video.</p>
|
<p class="vjs-no-js">Your browser does not support HTML video.</p>
|
||||||
</video>
|
</video>
|
||||||
@@ -286,6 +289,13 @@ render_head('Media — TyClifford.com', '
|
|||||||
<section class="cat-section">
|
<section class="cat-section">
|
||||||
<div class="cat-header">
|
<div class="cat-header">
|
||||||
<p class="eyebrow eyebrow-purple">Video Catalogue</p>
|
<p class="eyebrow eyebrow-purple">Video Catalogue</p>
|
||||||
|
<form class="cat-search" method="get" action="catalogue.php" role="search">
|
||||||
|
<input class="form-input" type="search" name="q" placeholder="Search public videos…">
|
||||||
|
<button class="btn btn-secondary btn-sm" type="submit">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<a href="catalogue.php" class="btn btn-secondary btn-sm">Browse all →</a>
|
<a href="catalogue.php" class="btn btn-secondary btn-sm">Browse all →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,7 +349,7 @@ render_head('Media — TyClifford.com', '
|
|||||||
</span>
|
</span>
|
||||||
<?php
|
<?php
|
||||||
$url_v = $video ? '&v=' . urlencode($video['slug']) : '';
|
$url_v = $video ? '&v=' . urlencode($video['slug']) : '';
|
||||||
render_pagination($page, $paged['total_pages'], '?page=%d' . $url_v);
|
render_pagination($paged['page'], $paged['total_pages'], '?page=%d' . $url_v);
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user