- Implemented audio support with Video.js.

What changed:
Added uploaded/direct audio playback paths in 
[index.php](/Users/tyemeclifford/Documents/GH/mediaplayer/index.php) and 
[embed.php](/Users/tyemeclifford/Documents/GH/mediaplayer/embed.php).
Added common audio upload support: MP3, M4A, AAC, WAV, FLAC, OGG, OGA, 
OPUS.
Added waveform poster generation for uploaded audio via ffmpeg, stored 
like normal thumbnails under media/thumbs/.
Updated add/edit forms to accept media files, not just video files.
Added audio-friendly labels like Audio, 320kbps, 256kbps, 128kbps.
Updated docs and changelog.
This commit is contained in:
Ty Clifford
2026-06-25 13:34:20 -04:00
parent 8a39bc02fa
commit 7deae1a399
10 changed files with 365 additions and 80 deletions
+11 -8
View File
@@ -44,16 +44,16 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
│ └── layout.php ← Shared HTML head, topbar, footer, pagination
├── admin/
│ ├── index.php ← Video list with search + pagination
│ ├── add.php ← Add new video with uploaded or external URL sources
│ ├── edit.php ← Edit video metadata + manage local/remote sources
│ ├── add.php ← Add new media with uploaded or external URL sources
│ ├── edit.php ← Edit media metadata + manage local/remote sources
│ ├── youtube_import.php ← Scrape a YouTube channel and import videos
│ ├── 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
│ ├── videos/ ← Uploaded media files
│ └── thumbs/ ← Uploaded thumbnails and generated waveforms
└── data/
└── media.db ← SQLite database (auto-created)
```
@@ -62,18 +62,19 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
## Usage
### Adding Videos (Admin)
### Adding Media (Admin)
1. Go to `admin/add.php`
2. Fill in title, description, duration, sort order, or paste an external URL and click **Fetch Info** to auto-fill readable metadata
3. Upload a thumbnail (JPG/PNG/WebP)
4. Add one or more video sources. Each source can be either:
- an uploaded local video file, stored under `media/videos/`
4. Add one or more media sources. Each source can be either:
- an uploaded local video or audio file, stored under `media/videos/`
- an external URL, stored in SQLite and played as a direct media source or provider embed
5. For uploaded files, supported formats include:
- **MP4** (H.264) — widest compatibility
- **WebM** (VP9) — smaller size, modern browsers
- **OGV** — Firefox fallback (legacy)
- **MP3 / M4A / AAC / WAV / FLAC / OGG / OPUS** — common audio formats
6. Set quality labels (1080p, 720p, 480p, etc.)
7. Save — Video.js will use the stored source location and auto-select the best format the browser supports
@@ -81,7 +82,7 @@ External URLs must use `http://` or `https://`. Uploaded files and remote URLs c
When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used.
If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server.
If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server, and local uploaded audio can generate a waveform poster image.
### Live Homepage Option
@@ -142,6 +143,8 @@ Use the **Sort Order** field — lower numbers appear first. Default is 0. The *
- Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2×
- Multiple source fallback — browser picks best format automatically
- Local uploaded sources and remote URL sources
- Audio playback for uploaded or direct linked audio sources
- Generated waveform poster images for uploaded audio when `ffmpeg` is available
- Provider embeds for popular video and audio platforms
- Thumbnail posters
- Per-video view counts and optional public library statistics
+27 -19
View File
@@ -88,7 +88,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$inserted_sources = 0;
$moved_files = [];
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
$allowed_media = media_allowed_upload_extensions();
for ($i = 0; $i < $source_count; $i++) {
$type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
@@ -99,7 +99,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($type === 'remote') {
$url = normalize_media_url($source_urls[$i] ?? '');
if ($url === '') {
if (trim($source_urls[$i] ?? '') !== '') $error = 'External video URLs must start with http:// or https://.';
if (trim($source_urls[$i] ?? '') !== '') $error = 'External media URLs must start with http:// or https://.';
continue;
}
$metadata = $source_metadata[$url] ?? [];
@@ -119,15 +119,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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;
if (!in_array($ext, $allowed_media, true)) {
$error = 'Uploaded media files must be one of: ' . media_upload_format_summary() . '.';
break;
}
$fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$moved_files[] = $videos_dir . $fname;
$target = $videos_dir . $fname;
$moved_files[] = $target;
$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]);
")->execute([$video_id, $label, 'local', 'videos/' . $fname, '', media_mime_from_local_file($target, $fname), $quality, $sort]);
$inserted_sources++;
}
}
@@ -154,7 +158,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
render_head('Add Video — Admin', '
render_head('Add Media — 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; }
@@ -206,7 +210,7 @@ render_head('Add Video — Admin', '
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">admin / add video</div>
<div class="brand-sub">admin / add media</div>
</div>
</div>
<nav class="topbar-social">
@@ -234,7 +238,7 @@ render_head('Add Video — Admin', '
</a>
<a href="add.php" class="active">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
Add Media
</a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
@@ -251,7 +255,7 @@ render_head('Add Video — Admin', '
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;flex-wrap:wrap;gap:.75rem;">
<div>
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">New Entry</p>
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);">Add Video</h1>
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);">Add Media</h1>
</div>
<a href="index.php" class="btn btn-secondary btn-sm">← Back</a>
</div>
@@ -260,7 +264,7 @@ render_head('Add Video — Admin', '
<!-- ── Video details ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Video Details</p>
<p class="section-heading">Media Details</p>
<div class="form-grid">
<div class="form-group span2">
<label class="form-label" for="title">Title</label>
@@ -290,9 +294,9 @@ render_head('Add Video — Admin', '
accept="image/jpeg,image/png,image/webp,image/gif" style="padding:.45rem .6rem;">
<label style="display:flex;align-items:center;gap:.55rem;margin-top:.55rem;cursor:pointer;">
<input type="checkbox" name="auto_thumbnail" value="1" <?= isset($_POST['auto_thumbnail']) || $_SERVER['REQUEST_METHOD'] !== 'POST' ? 'checked' : '' ?>>
<span style="font-size:.78rem;color:var(--text-2);">Auto-grab thumbnail from source if no image is uploaded</span>
<span style="font-size:.78rem;color:var(--text-2);">Auto-grab thumbnail or waveform from source if no image is uploaded</span>
</label>
<span class="hint">JPG, PNG, WebP or GIF. Auto-grab supports YouTube, Vimeo, Dailymotion and other oEmbed providers; local uploads use ffmpeg when available.</span>
<span class="hint">JPG, PNG, WebP or GIF. Auto-grab supports providers, local video first frames, and local audio waveforms when ffmpeg is available.</span>
</div>
<div class="form-group" style="justify-content:flex-end;padding-bottom:.2rem;">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;">
@@ -304,12 +308,12 @@ render_head('Add Video — Admin', '
</div>
</div>
<!-- ── Video sources ── -->
<!-- ── Media sources ── -->
<div class="card">
<p class="section-heading">Video Sources</p>
<p class="section-heading">Media Sources</p>
<p class="hint" style="margin-bottom:1rem;">
Upload a video file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. The player will use a provider embed when the URL supports it.
Uploaded files: <strong>MP4, WebM, OGV, MOV, MKV, AVI</strong>. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>.
Upload a video or audio file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. The player will use a provider embed when the URL supports it.
Uploaded files: <strong><?= h(media_upload_format_summary()) ?></strong>. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>.
</p>
<div class="sources-section" id="sources-container">
@@ -323,9 +327,9 @@ render_head('Add Video — Admin', '
</select>
</div>
<div class="form-group source-location-panel source-location-local">
<label class="form-label">Video File</label>
<label class="form-label">Media File</label>
<input class="form-input" type="file" name="source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
accept="<?= h(media_upload_accept_attribute()) ?>"
style="padding:.45rem .6rem;">
</div>
<div class="form-group source-location-panel source-location-remote" hidden>
@@ -344,6 +348,10 @@ render_head('Add Video — Admin', '
<option value="480p">480p</option>
<option value="360p">360p</option>
<option value="4K">4K</option>
<option value="Audio">Audio</option>
<option value="320kbps">320kbps</option>
<option value="256kbps">256kbps</option>
<option value="128kbps">128kbps</option>
<option value="">—</option>
</select>
</div>
@@ -367,7 +375,7 @@ render_head('Add Video — Admin', '
<div style="display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap;">
<button class="btn btn-primary" type="submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
Save Video
Save Media
</button>
<a href="index.php" class="btn btn-secondary">Cancel</a>
</div>
+22 -14
View File
@@ -94,7 +94,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($source_type === 'remote') {
$source_url = normalize_media_url($upd_urls[$i] ?? '');
if ($source_url === '') {
$error = 'External video URLs must start with http:// or https://.';
$error = 'External media URLs must start with http:// or https://.';
break;
}
}
@@ -127,7 +127,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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'];
$allowed_media = media_allowed_upload_extensions();
$new_count = max(
count($new_types),
@@ -147,7 +147,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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://.';
if (trim($new_urls[$i] ?? '') !== '') $error = 'External media URLs must start with http:// or https://.';
continue;
}
@@ -163,14 +163,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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;
if (!in_array($ext, $allowed_media, true)) {
$error = 'Uploaded media files must be one of: ' . media_upload_format_summary() . '.';
break;
}
$fname = $slug . '_' . time() . '_' . $i . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$moved_files[] = $videos_dir . $fname;
$target = $videos_dir . $fname;
$moved_files[] = $target;
$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]);
")->execute([$id, $label, 'local', 'videos/' . $fname, '', media_mime_from_local_file($target, $fname), $quality, $sort]);
$inserted_source_ids[] = (int)$db->lastInsertId();
}
}
@@ -278,7 +282,7 @@ render_head('Edit Video — Admin', '
</a>
<a href="add.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
Add Media
</a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
@@ -305,7 +309,7 @@ render_head('Edit Video — Admin', '
<!-- ── Details ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Video Details</p>
<p class="section-heading">Media Details</p>
<div class="form-grid">
<div class="form-group span2">
<label class="form-label" for="title">Title *</label>
@@ -359,9 +363,9 @@ render_head('Edit Video — Admin', '
accept="image/jpeg,image/png,image/webp,image/gif" style="padding:.45rem .6rem;">
<label style="display:flex;align-items:center;gap:.55rem;margin-top:.55rem;cursor:pointer;">
<input type="checkbox" name="auto_thumbnail" value="1" <?= isset($_POST['auto_thumbnail']) || ($_SERVER['REQUEST_METHOD'] !== 'POST' && !$video['thumbnail']) ? 'checked' : '' ?>>
<span style="font-size:.78rem;color:var(--text-2);">Auto-grab/refresh thumbnail from source on save</span>
<span style="font-size:.78rem;color:var(--text-2);">Auto-grab/refresh thumbnail or waveform from source on save</span>
</label>
<span class="hint">Upload a new image to override auto-grab. Auto-grab supports YouTube, Vimeo, Dailymotion and other oEmbed providers; local uploads use ffmpeg when available.</span>
<span class="hint">Upload a new image to override auto-grab. Auto-grab supports providers, local video first frames, and local audio waveforms when ffmpeg is available.</span>
</div>
</div>
</div>
@@ -402,7 +406,7 @@ render_head('Edit Video — Admin', '
<div class="form-group">
<label class="form-label">Quality</label>
<select class="form-select" name="src_quality[]">
<?php foreach (['1080p','720p','480p','360p','4K',''] as $q): ?>
<?php foreach (['1080p','720p','480p','360p','4K','Audio','320kbps','256kbps','128kbps',''] as $q): ?>
<option value="<?= $q ?>" <?= $s['quality']===$q ? 'selected' : '' ?>><?= $q ?: '—' ?></option>
<?php endforeach; ?>
</select>
@@ -425,7 +429,7 @@ render_head('Edit Video — Admin', '
<!-- ── Add new sources ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Add New Sources</p>
<p class="hint" style="margin-bottom:.85rem;">Upload another file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo.</p>
<p class="hint" style="margin-bottom:.85rem;">Upload another video or audio file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. Uploaded files: <strong><?= h(media_upload_format_summary()) ?></strong>.</p>
<div class="source-list" id="new-sources-container">
<div class="add-source-row" data-new-row="0">
<div class="form-group">
@@ -436,9 +440,9 @@ render_head('Edit Video — Admin', '
</select>
</div>
<div class="form-group source-location-panel source-location-local">
<label class="form-label">Video File</label>
<label class="form-label">Media File</label>
<input class="form-input" type="file" name="new_source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
accept="<?= h(media_upload_accept_attribute()) ?>"
style="padding:.45rem .6rem;">
</div>
<div class="form-group source-location-panel source-location-remote" hidden>
@@ -453,6 +457,10 @@ render_head('Edit Video — Admin', '
<option value="480p">480p</option>
<option value="360p">360p</option>
<option value="4K">4K</option>
<option value="Audio">Audio</option>
<option value="320kbps">320kbps</option>
<option value="256kbps">256kbps</option>
<option value="128kbps">128kbps</option>
<option value="">—</option>
</select>
</div>
+2 -2
View File
@@ -205,7 +205,7 @@ render_head('Admin — TyClifford.com', '
</a>
<a href="add.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
Add Media
</a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
@@ -295,7 +295,7 @@ render_head('Admin — TyClifford.com', '
</form>
<a href="add.php" class="btn btn-primary btn-sm">
<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 Video
Add Media
</a>
<a href="youtube_import.php" class="btn btn-secondary btn-sm">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
+1 -1
View File
@@ -132,7 +132,7 @@ render_head('Settings — Admin', '
</a>
<a href="add.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
Add Media
</a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
+1 -1
View File
@@ -600,7 +600,7 @@ render_head('YouTube Importer — Admin', '
<aside class="admin-nav">
<div class="admin-nav-label">Navigation</div>
<a href="index.php">Videos</a>
<a href="add.php">Add Video</a>
<a href="add.php">Add Media</a>
<a href="youtube_import.php" class="active">YouTube Importer</a>
<hr class="nav-divider">
<a href="settings.php">Settings</a>
+2
View File
@@ -11,6 +11,8 @@ All notable changes to this media player project.
- Added an admin upload flow that supports either direct uploads or external provider/page URLs.
- Added metadata fetching for external URLs to populate title, description, duration, thumbnails, publish dates, and readable provider view counts where available.
- Added automatic thumbnail grabbing for external URLs, plus first-frame thumbnail generation for local videos when `ffmpeg` is available.
- Added Video.js audio playback for uploaded and direct linked audio sources, including WAV and other common audio formats.
- Added generated waveform poster images for uploaded audio files when `ffmpeg` is available.
- Added a YouTube channel importer that scrapes channel videos, previews imports, skips duplicates, and stores imported videos as embedded YouTube sources.
- Added YouTube timestamp correction so imported and existing YouTube videos can use their original publish dates.
- Added an **Upkeeping** section to the admin dashboard for batch maintenance actions.
+84 -4
View File
@@ -33,6 +33,13 @@ $native_player_sources = [];
foreach ($player_sources as $player_source) {
if (($player_source['kind'] ?? '') !== 'iframe') $native_player_sources[] = $player_source;
}
$native_player_mode = (($primary_player_source['kind'] ?? '') === 'audio') ? 'audio' : 'video';
$active_native_sources = [];
foreach ($native_player_sources as $player_source) {
$kind = $player_source['kind'] ?? '';
if ($native_player_mode === 'audio' && $kind === 'audio') $active_native_sources[] = $player_source;
if ($native_player_mode === 'video' && $kind !== 'audio') $active_native_sources[] = $player_source;
}
$meta_title = $video ? $video['title'] . ' — TyClifford.com' : 'Video Player';
$meta_description = $video && trim($video['description'] ?? '') !== '' ? trim($video['description']) : 'TyClifford.com media player';
@@ -153,6 +160,57 @@ header('Content-Security-Policy: frame-ancestors *');
.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; }
.audio-player-frame {
position: absolute;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 1rem;
background: radial-gradient(circle at 50% 35%, rgba(176,96,255,.12), transparent 55%), #09090f;
}
.audio-waveform-art {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, rgba(9,9,15,.2), rgba(9,9,15,.82));
overflow: hidden;
}
.audio-waveform-art img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: .92;
filter: saturate(1.15) contrast(1.05);
}
.audio-waveform-fallback {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, transparent 0 4%, rgba(0,232,122,.34) 4% 5%, transparent 5% 9%),
radial-gradient(circle at 50% 35%, rgba(0,232,122,.12), transparent 55%),
#09090f;
background-size: 90px 100%, 100% 100%, 100% 100%;
opacity: .9;
}
.audio-player-frame .video-js {
position: relative !important;
inset: auto !important;
width: 100% !important;
max-width: calc(100% - 1rem) !important;
height: 4.2rem !important;
background: rgba(9,9,15,.86) !important;
border: 1px solid rgba(255,255,255,.08);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
.audio-player-frame .video-js .vjs-control-bar {
background: rgba(9,9,15,.92) !important;
height: 4.2rem !important;
}
.provider-embed-frame {
position: absolute;
inset: 0;
@@ -240,14 +298,35 @@ header('Content-Security-Policy: frame-ancestors *');
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"></iframe>
<?php elseif (!empty($native_player_sources)): ?>
<?php elseif (!empty($active_native_sources)): ?>
<?php if ($native_player_mode === 'audio'): ?>
<div class="audio-player-frame">
<div class="audio-waveform-art" aria-hidden="true">
<?php if ($video['thumbnail']): ?>
<img src="<?= h(public_absolute_url(public_media_url($video['thumbnail']))) ?>" alt="">
<?php else: ?>
<div class="audio-waveform-fallback"></div>
<?php endif; ?>
</div>
<audio id="embed-player"
class="video-js vjs-default-skin"
controls preload="metadata">
<?php foreach ($active_native_sources as $src): ?>
<source
src="<?= htmlspecialchars($src['url'], ENT_QUOTES) ?>"
type="<?= htmlspecialchars($src['mime'], ENT_QUOTES) ?>">
<?php endforeach; ?>
<p class="vjs-no-js">Your browser does not support HTML5 audio.</p>
</audio>
</div>
<?php else: ?>
<video id="embed-player"
class="video-js vjs-default-skin vjs-big-play-centered"
controls preload="metadata"
<?php if ($video['thumbnail']): ?>
poster="<?= htmlspecialchars(public_absolute_url(public_media_url($video['thumbnail'])), ENT_QUOTES) ?>"
<?php endif; ?>>
<?php foreach ($native_player_sources as $src): ?>
<?php foreach ($active_native_sources as $src): ?>
<source
src="<?= htmlspecialchars($src['url'], ENT_QUOTES) ?>"
type="<?= htmlspecialchars($src['mime'], ENT_QUOTES) ?>">
@@ -255,6 +334,7 @@ header('Content-Security-Policy: frame-ancestors *');
<p class="vjs-no-js">Your browser does not support HTML5 video.</p>
</video>
<?php endif; ?>
<?php endif; ?>
<?php else: ?>
@@ -265,13 +345,13 @@ header('Content-Security-Policy: frame-ancestors *');
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<p><?= $slug ? 'video not found' : 'no video specified' ?></p>
<p><?= $slug ? 'media not found' : 'no media specified' ?></p>
</div>
<?php endif; ?>
</div>
<?php if ($video && !empty($native_player_sources) && (!$primary_player_source || ($primary_player_source['kind'] ?? '') !== 'iframe')): ?>
<?php if ($video && !empty($active_native_sources) && (!$primary_player_source || ($primary_player_source['kind'] ?? '') !== 'iframe')): ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
var player = videojs('embed-player', {
+122 -5
View File
@@ -321,6 +321,58 @@ function make_slug(string $title, int $id = 0): string {
return $slug ?: 'video-' . time();
}
function media_video_upload_extensions(): array {
return ['mp4', 'm4v', 'webm', 'ogv', 'mov', 'mkv', 'avi'];
}
function media_audio_upload_extensions(): array {
return ['mp3', 'm4a', 'aac', 'wav', 'wave', 'flac', 'ogg', 'oga', 'opus'];
}
function media_allowed_upload_extensions(): array {
return array_values(array_unique(array_merge(media_video_upload_extensions(), media_audio_upload_extensions())));
}
function media_upload_extension_allowed(string $ext): bool {
return in_array(strtolower(trim($ext)), media_allowed_upload_extensions(), true);
}
function media_upload_accept_attribute(): string {
return implode(',', [
'video/mp4',
'video/webm',
'video/ogg',
'video/quicktime',
'audio/mpeg',
'audio/mp4',
'audio/aac',
'audio/wav',
'audio/x-wav',
'audio/flac',
'audio/ogg',
'.mp4',
'.m4v',
'.webm',
'.ogv',
'.mov',
'.mkv',
'.avi',
'.mp3',
'.m4a',
'.aac',
'.wav',
'.wave',
'.flac',
'.ogg',
'.oga',
'.opus',
]);
}
function media_upload_format_summary(): string {
return 'MP4, M4V, WebM, OGV, MOV, MKV, AVI, MP3, M4A, AAC, WAV, FLAC, OGG, OPUS';
}
function mime_from_ext(string $path): string {
$url_path = parse_url($path, PHP_URL_PATH);
if (is_string($url_path) && $url_path !== '') {
@@ -332,7 +384,6 @@ function mime_from_ext(string $path): string {
'm4v' => 'video/mp4',
'webm' => 'video/webm',
'ogv' => 'video/ogg',
'ogg' => 'video/ogg',
'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska',
'avi' => 'video/x-msvideo',
@@ -342,11 +393,41 @@ function mime_from_ext(string $path): string {
'm4a' => 'audio/mp4',
'aac' => 'audio/aac',
'wav' => 'audio/wav',
'wave' => 'audio/wav',
'flac' => 'audio/flac',
'ogg' => 'audio/ogg',
'oga' => 'audio/ogg',
'opus' => 'audio/ogg',
default => 'video/mp4',
};
}
function media_mime_from_local_file(string $path, string $fallback_name = ''): string {
$mime = '';
if (is_file($path) && function_exists('finfo_open')) {
$finfo = @finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$detected = @finfo_file($finfo, $path);
if (is_string($detected)) $mime = strtolower(trim($detected));
@finfo_close($finfo);
}
}
$fallback_source = $fallback_name !== '' ? $fallback_name : $path;
$fallback = mime_from_ext($fallback_source);
$ext = strtolower(pathinfo($fallback_source, PATHINFO_EXTENSION));
if ($mime === '' || in_array($mime, ['application/octet-stream', 'binary/octet-stream'], true)) return $fallback;
if ($mime === 'audio/x-wav' || $mime === 'audio/vnd.wave') return 'audio/wav';
if ($mime === 'audio/x-flac') return 'audio/flac';
if ($mime === 'video/x-msvideo') return 'video/x-msvideo';
if ($mime === 'application/ogg') {
return $ext === 'ogv' ? 'video/ogg' : 'audio/ogg';
}
if (in_array($ext, media_audio_upload_extensions(), true) && str_starts_with($mime, 'video/')) return $fallback;
if (str_starts_with($mime, 'audio/') || str_starts_with($mime, 'video/')) return $mime;
return $fallback;
}
function normalize_media_url(string $url): string {
$url = trim($url);
if ($url === '') return '';
@@ -389,11 +470,12 @@ function media_extension_from_url(string $url): string {
function media_guess_mime(string $url): string {
$map = [
'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm',
'ogv' => 'video/ogg', 'ogg' => 'video/ogg', 'mov' => 'video/quicktime',
'ogv' => 'video/ogg', 'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska', 'avi' => 'video/x-msvideo',
'm3u8' => 'application/vnd.apple.mpegurl', 'mpd' => 'application/dash+xml',
'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'aac' => 'audio/aac',
'wav' => 'audio/wav', 'flac' => 'audio/flac',
'wav' => 'audio/wav', 'wave' => 'audio/wav', 'flac' => 'audio/flac',
'ogg' => 'audio/ogg', 'oga' => 'audio/ogg', 'opus' => 'audio/ogg',
];
return $map[media_extension_from_url($url)] ?? '';
}
@@ -1182,6 +1264,37 @@ function media_thumbnail_from_local_video(string $file_path, string $slug): stri
return '';
}
function media_waveform_from_local_audio(string $file_path, string $slug): string {
$ffmpeg = media_ffmpeg_path();
if ($ffmpeg === '') return '';
$source = MEDIA_DIR . ltrim($file_path, '/');
if ($file_path === '' || !is_file($source) || !is_readable($source)) return '';
$mime = media_mime_from_local_file($source, $file_path);
if (media_classify_kind($file_path, $mime) !== 'audio') return '';
$thumb_dir = MEDIA_DIR . 'thumbs/';
if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true);
$fname = $slug . '_waveform_' . time() . '_' . substr(sha1($file_path), 0, 8) . '.png';
$target = $thumb_dir . $fname;
if (!function_exists('exec') || media_disabled_function('exec')) return '';
$filter = '[0:a]aformat=channel_layouts=mono,showwavespic=s=1280x720:colors=0x00e87a[wave];'
. 'color=c=0x09090f:s=1280x720[bg];'
. '[bg][wave]overlay=shortest=1,drawbox=x=0:y=0:w=iw:h=2:color=0xb060ff@0.9:t=fill';
$cmd = escapeshellarg($ffmpeg) . ' -y -i ' . escapeshellarg($source)
. ' -filter_complex ' . escapeshellarg($filter)
. ' -frames:v 1 ' . escapeshellarg($target) . ' 2>/dev/null';
@exec($cmd, $output, $code);
if ($code === 0 && is_file($target) && filesize($target) > 0 && (!function_exists('getimagesize') || @getimagesize($target))) {
return 'thumbs/' . $fname;
}
if (is_file($target)) unlink($target);
return '';
}
function media_auto_thumbnail_for_video(int $video_id, string $slug): string {
$stmt = get_db()->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC, id ASC");
$stmt->execute([$video_id]);
@@ -1196,6 +1309,8 @@ function media_auto_thumbnail_for_video(int $video_id, string $slug): string {
foreach ($sources as $source) {
if (!source_is_local($source)) continue;
$thumbnail = media_waveform_from_local_audio($source['file_path'] ?? '', $slug);
if ($thumbnail !== '') return $thumbnail;
$thumbnail = media_thumbnail_from_local_video($source['file_path'] ?? '', $slug);
if ($thumbnail !== '') return $thumbnail;
}
@@ -1352,8 +1467,8 @@ function media_classify_kind(string $url, string $mime, string $forced_mode = 'a
if ($ext === 'mpd' || $mime_lc === 'application/dash+xml') return 'dash';
if (str_starts_with($mime_lc, 'audio/')) return 'audio';
if (str_starts_with($mime_lc, 'video/')) return 'video';
if (in_array($ext, ['mp3', 'm4a', 'aac', 'wav', 'flac', 'oga', 'opus'], true)) return 'audio';
if (in_array($ext, ['mp4', 'm4v', 'webm', 'ogv', 'ogg', 'mov', 'mkv', 'avi', 'wmv', 'flv', 'ts', '3gp', '3g2'], true)) return 'video';
if (in_array($ext, ['mp3', 'm4a', 'aac', 'wav', 'wave', 'flac', 'ogg', 'oga', 'opus'], true)) return 'audio';
if (in_array($ext, ['mp4', 'm4v', 'webm', 'ogv', 'mov', 'mkv', 'avi', 'wmv', 'flv', 'ts', '3gp', '3g2'], true)) return 'video';
return 'unknown';
}
@@ -1429,6 +1544,8 @@ function source_provider_label(array $source): string {
if (!$player) return source_is_remote($source) ? 'External URL' : 'Uploaded File';
if (($player['provider'] ?? '') !== '') return ucwords(str_replace('-', ' ', $player['provider'])) . ' Embed';
if (($player['kind'] ?? '') === 'iframe') return 'External Embed';
if (($player['kind'] ?? '') === 'audio') return source_is_remote($source) ? 'External Audio URL' : 'Uploaded Audio';
if (($player['kind'] ?? '') === 'video') return source_is_remote($source) ? 'External Video URL' : 'Uploaded Video';
return source_is_remote($source) ? 'External URL' : 'Uploaded File';
}
+70 -3
View File
@@ -58,6 +58,13 @@ $native_player_sources = [];
foreach ($player_sources as $player_source) {
if (($player_source['kind'] ?? '') !== 'iframe') $native_player_sources[] = $player_source;
}
$native_player_mode = (($primary_player_source['kind'] ?? '') === 'audio') ? 'audio' : 'video';
$active_native_sources = [];
foreach ($native_player_sources as $player_source) {
$kind = $player_source['kind'] ?? '';
if ($native_player_mode === 'audio' && $kind === 'audio') $active_native_sources[] = $player_source;
if ($native_player_mode === 'video' && $kind !== 'audio') $active_native_sources[] = $player_source;
}
$site_title = setting('site_title', 'Ty Clifford');
if ($home_live_first) {
@@ -189,6 +196,45 @@ render_head($share_title, '
.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; }
.audio-player-frame {
position:absolute; inset:0;
display:flex; align-items:flex-end; justify-content:center;
padding:1rem;
background:radial-gradient(circle at 50% 35%, rgba(176,96,255,.12), transparent 55%), #09090f;
}
.audio-waveform-art {
position:absolute; inset:0;
display:flex; align-items:center; justify-content:center;
background:linear-gradient(180deg,rgba(9,9,15,.2),rgba(9,9,15,.82));
overflow:hidden;
}
.audio-waveform-art img {
width:100%; height:100%; object-fit:cover;
opacity:.92; filter:saturate(1.15) contrast(1.05);
}
.audio-waveform-fallback {
position:absolute; inset:0;
background:
linear-gradient(90deg, transparent 0 4%, rgba(0,232,122,.34) 4% 5%, transparent 5% 9%),
radial-gradient(circle at 50% 35%, rgba(0,232,122,.12), transparent 55%),
#09090f;
background-size:90px 100%, 100% 100%, 100% 100%;
opacity:.9;
}
.audio-player-frame .video-js {
position:relative !important; inset:auto !important;
width:100% !important; max-width:calc(100% - 1rem) !important;
height:4.2rem !important;
background:rgba(9,9,15,.86) !important;
border:1px solid rgba(255,255,255,.08);
border-radius:var(--r);
overflow:hidden;
box-shadow:0 10px 30px rgba(0,0,0,.35);
}
.audio-player-frame .video-js .vjs-control-bar {
background:rgba(9,9,15,.92) !important;
height:4.2rem !important;
}
.provider-embed-frame {
position:absolute; inset:0;
display:block; width:100%; height:100%;
@@ -346,7 +392,27 @@ render_head($share_title, '
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"></iframe>
<?php elseif ($video && !empty($native_player_sources)): ?>
<?php elseif ($video && !empty($active_native_sources)): ?>
<?php if ($native_player_mode === 'audio'): ?>
<div class="audio-player-frame">
<div class="audio-waveform-art" aria-hidden="true">
<?php if ($video['thumbnail']): ?>
<img src="<?= h(public_media_url($video['thumbnail'])) ?>" alt="">
<?php else: ?>
<div class="audio-waveform-fallback"></div>
<?php endif; ?>
</div>
<audio id="main-player"
class="video-js vjs-default-skin"
controls preload="metadata"
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
<?php foreach ($active_native_sources as $src): ?>
<source src="<?= h($src['url']) ?>" type="<?= h($src['mime']) ?>" label="<?= h($src['label']) ?>">
<?php endforeach; ?>
<p class="vjs-no-js">Your browser does not support HTML audio.</p>
</audio>
</div>
<?php else: ?>
<video id="main-player"
class="video-js vjs-default-skin vjs-big-play-centered"
controls preload="metadata"
@@ -354,15 +420,16 @@ render_head($share_title, '
poster="<?= h(public_media_url($video['thumbnail'])) ?>"
<?php endif; ?>
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
<?php foreach ($native_player_sources as $src): ?>
<?php foreach ($active_native_sources as $src): ?>
<source src="<?= h($src['url']) ?>" type="<?= h($src['mime']) ?>" label="<?= h($src['label']) ?>">
<?php endforeach; ?>
<p class="vjs-no-js">Your browser does not support HTML video.</p>
</video>
<?php endif; ?>
<?php else: ?>
<div class="no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".25"><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 video selected</p>
<p>no media selected</p>
</div>
<?php endif; ?>
</div>