- Live homepage:

Added default Live settings in 
[includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php) 
using https://stream.place/embed/tyclifford.com.
Added Admin → Settings → Live Section controls in 
[admin/settings.php](/Users/tyemeclifford/Documents/GH/mediaplayer/admin/settings.php): 
enable/disable, live title, and embed URL/iframe input.
Added a homepage Videos / Live toggle in 
[index.php](/Users/tyemeclifford/Documents/GH/mediaplayer/index.php). 
Videos remains the default view, so the homepage behaves normally until 
Live is selected.
Imgur:
Added Imgur URL normalization, embed support, oEmbed/thumbnail hooks, 
and external view count scraping support in 
[includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).
Supports imgur.com, albums/galleries, and direct i.imgur.com 
photos/GIFs/videos by routing them through Imgur embeds.
Imgur external views now flow into external_view_count, so per-item and 
overall site totals include readable Imgur provider counts.
Updated the add-video hint and README docs.
This commit is contained in:
Ty Clifford
2026-06-25 07:38:19 -04:00
parent 08caf98fa5
commit a3aa51c4e2
5 changed files with 187 additions and 5 deletions
+6 -3
View File
@@ -3,7 +3,6 @@
A self-hosted multimedia player website built with PHP + SQLite + Video.js. A self-hosted multimedia player website built with PHP + SQLite + Video.js.
Styled to match the existing `gate.php` / `index.php` dark theme exactly. Styled to match the existing `gate.php` / `index.php` dark theme exactly.
<iframe src="https://docs.google.com/presentation/d/e/2PACX-1vQt7Hl5PtsdYbTXvRTWxMm_3BKj3PrAIyUeMUFn5_t3vwJRX_Aa3yG__bWpmogaxA/pubembed?start=false&loop=false&delayms=3000" frameborder="0" width="1280" height="749" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>
--- ---
@@ -78,12 +77,16 @@ Styled to match the existing `gate.php` / `index.php` dark theme exactly.
6. Set quality labels (1080p, 720p, 480p, etc.) 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 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. Recognized provider URLs such as YouTube, Vimeo, Dailymotion, Twitch, Facebook, TikTok, Instagram, X/Twitter, SoundCloud, Spotify, Google Drive, Wistia, Streamable, and Loom render through their embed players. External URLs must use `http://` or `https://`. Uploaded files and remote URLs can be mixed on the same video. Recognized provider URLs such as YouTube, Vimeo, Dailymotion, Imgur, Twitch, Facebook, TikTok, Instagram, X/Twitter, SoundCloud, Spotify, Google Drive, Wistia, Streamable, and Loom render through their embed players.
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. 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.
### Live Homepage Option
The homepage can show a **Videos / Live** switch above the main player. Configure it in **Admin → Settings → Live Section** with either a direct embeddable URL or an iframe snippet. The default live embed is `https://stream.place/embed/tyclifford.com`. Disable the setting to return the homepage to the normal video-only layout.
### Importing YouTube Channels ### Importing YouTube Channels
Use **Admin → YouTube Importer** to paste a YouTube channel URL, `@handle`, or `UC...` channel ID. The importer scrapes the channel's `/videos` page, extracts video data from `ytInitialData`, previews the results, and imports selected videos as remote YouTube embed sources. Duplicate YouTube watch URLs are skipped. Use **Admin → YouTube Importer** to paste a YouTube channel URL, `@handle`, or `UC...` channel ID. The importer scrapes the channel's `/videos` page, extracts video data from `ytInitialData`, previews the results, and imports selected videos as remote YouTube embed sources. Duplicate YouTube watch URLs are skipped.
@@ -100,7 +103,7 @@ The public display is configurable in **Admin → Settings → Public Display**:
- show or hide the total public view count - show or hide the total public view count
- show or hide public page statistics on the homepage and catalogue - show or hide public page statistics on the homepage and catalogue
The admin dashboard also includes **Upkeeping → Correct external view counts**. It checks existing remote-source videos in batches, scrapes readable provider counts when available, and adds those provider totals to the media-site totals without double-counting local plays. YouTube has dedicated parsing, and generic metadata patterns cover readable Vimeo, Dailymotion, and similar provider pages when they expose counts. Unsupported providers and local-only uploads can still be corrected manually on the edit screen. The admin dashboard also includes **Upkeeping → Correct external view counts**. It checks existing remote-source videos in batches, scrapes readable provider counts when available, and adds those provider totals to the media-site totals without double-counting local plays. YouTube and Imgur have dedicated handling, and generic metadata patterns cover readable Vimeo, Dailymotion, and similar provider pages when they expose counts. Unsupported providers and local-only uploads can still be corrected manually on the edit screen.
### Public Search ### Public Search
+1 -1
View File
@@ -308,7 +308,7 @@ render_head('Add Video — Admin', '
<div class="card"> <div class="card">
<p class="section-heading">Video Sources</p> <p class="section-heading">Video Sources</p>
<p class="hint" style="margin-bottom:1rem;"> <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. Upload a video file, link a direct media file, or paste a provider page URL such as YouTube, Vimeo, or Imgur. 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>. Uploaded files: <strong>MP4, WebM, OGV, MOV, MKV, AVI</strong>. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>.
</p> </p>
+30
View File
@@ -17,6 +17,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$show_video_views = isset($_POST['public_show_video_views']) ? '1' : '0'; $show_video_views = isset($_POST['public_show_video_views']) ? '1' : '0';
$show_total_views = isset($_POST['public_show_total_views']) ? '1' : '0'; $show_total_views = isset($_POST['public_show_total_views']) ? '1' : '0';
$show_page_stats = isset($_POST['public_show_page_stats']) ? '1' : '0'; $show_page_stats = isset($_POST['public_show_page_stats']) ? '1' : '0';
$live_enabled = isset($_POST['live_enabled']) ? '1' : '0';
$live_title = trim($_POST['live_title'] ?? 'Ty Clifford live stream');
$live_embed = trim($_POST['live_embed'] ?? '');
set_setting('catalogue_per_page', (string)$per_page); set_setting('catalogue_per_page', (string)$per_page);
set_setting('site_title', $site_title); set_setting('site_title', $site_title);
@@ -25,6 +28,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
set_setting('public_show_video_views', $show_video_views); set_setting('public_show_video_views', $show_video_views);
set_setting('public_show_total_views', $show_total_views); set_setting('public_show_total_views', $show_total_views);
set_setting('public_show_page_stats', $show_page_stats); set_setting('public_show_page_stats', $show_page_stats);
set_setting('live_enabled', $live_enabled);
set_setting('live_title', $live_title);
set_setting('live_embed', $live_embed);
$msg = 'Settings saved.'; $msg = 'Settings saved.';
} }
@@ -53,6 +59,9 @@ $url_rewrite_mode = url_rewrite_mode();
$public_show_video_views = setting_bool('public_show_video_views', true); $public_show_video_views = setting_bool('public_show_video_views', true);
$public_show_total_views = setting_bool('public_show_total_views', true); $public_show_total_views = setting_bool('public_show_total_views', true);
$public_show_page_stats = setting_bool('public_show_page_stats', true); $public_show_page_stats = setting_bool('public_show_page_stats', true);
$live_setting_enabled = setting_bool('live_enabled', true);
$live_title = setting('live_title', 'Ty Clifford live stream');
$live_embed = setting('live_embed', 'https://stream.place/embed/tyclifford.com');
// DB stats // DB stats
$db = get_db(); $db = get_db();
@@ -220,6 +229,27 @@ render_head('Settings — Admin', '
</span> </span>
</label> </label>
</div> </div>
<p class="form-label" style="margin-bottom:.55rem;">Live Section</p>
<div class="option-list" style="grid-template-columns:1fr;margin-bottom:1rem;">
<label class="check-option">
<input type="checkbox" name="live_enabled" value="1" <?= $live_setting_enabled ? 'checked' : '' ?>>
<span>
<strong>Show Live option</strong>
Adds a Live button on the homepage that switches from the normal video player to the configured live embed.
</span>
</label>
</div>
<div class="form-grid" style="margin-bottom:1rem;">
<div class="form-group">
<label class="form-label" for="live_title">Live Title</label>
<input class="form-input" type="text" id="live_title" name="live_title" value="<?= h($live_title) ?>">
</div>
<div class="form-group">
<label class="form-label" for="live_embed">Live Embed URL or iframe</label>
<textarea class="form-textarea" id="live_embed" name="live_embed" rows="4"><?= h($live_embed) ?></textarea>
<span class="hint">Paste an iframe embed code or a direct embeddable URL. Default: https://stream.place/embed/tyclifford.com</span>
</div>
</div>
<button class="btn btn-primary btn-sm" type="submit"> <button class="btn btn-primary btn-sm" type="submit">
<svg width="13" height="13" 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> <svg width="13" height="13" 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 Settings Save Settings
+90
View File
@@ -74,6 +74,9 @@ function get_db(): PDO {
'public_show_video_views' => '1', 'public_show_video_views' => '1',
'public_show_total_views' => '1', 'public_show_total_views' => '1',
'public_show_page_stats' => '1', 'public_show_page_stats' => '1',
'live_enabled' => '1',
'live_title' => 'Ty Clifford live stream',
'live_embed' => 'https://stream.place/embed/tyclifford.com',
]; ];
$ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)"); $ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
foreach ($defaults as $k => $v) $ins->execute([$k, $v]); foreach ($defaults as $k => $v) $ins->execute([$k, $v]);
@@ -171,6 +174,30 @@ function public_page_stats(): array {
]; ];
} }
function media_iframe_src_from_input(string $value): string {
$value = trim($value);
if ($value === '') return '';
if (stripos($value, '<iframe') !== false && preg_match('/\ssrc=["\']([^"\']+)["\']/i', $value, $match)) {
$value = html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return normalize_media_url($value);
}
function live_embed_url(): string {
return media_iframe_src_from_input(setting('live_embed', 'https://stream.place/embed/tyclifford.com'));
}
function live_embed_title(): string {
$title = trim(setting('live_title', 'Ty Clifford live stream'));
return $title !== '' ? $title : 'Live stream';
}
function live_enabled(): bool {
return setting_bool('live_enabled', true) && live_embed_url() !== '';
}
function url_rewrite_mode(): string { function url_rewrite_mode(): string {
$mode = strtolower(setting('url_rewrite_mode', 'pretty')); $mode = strtolower(setting('url_rewrite_mode', 'pretty'));
return in_array($mode, ['pretty', 'query'], true) ? $mode : 'pretty'; return in_array($mode, ['pretty', 'query'], true) ? $mode : 'pretty';
@@ -485,6 +512,41 @@ function media_youtube_id_from_url(string $url): string {
return media_clean_token($id); return media_clean_token($id);
} }
function media_imgur_path_from_url(string $url): string {
$url = normalize_media_url($url);
if ($url === '') return '';
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
$segments = media_path_segments($url);
if (!$segments) return '';
if (media_host_matches($host, 'i.imgur.com')) {
$id = pathinfo($segments[0], PATHINFO_FILENAME);
$id = media_clean_token($id);
return $id !== '' ? $id : '';
}
if (!media_host_matches($host, 'imgur.com')) return '';
if (in_array($segments[0], ['a', 'gallery'], true) && isset($segments[1])) {
$id = media_clean_token($segments[1]);
return $id !== '' ? $segments[0] . '/' . $id : '';
}
$id = media_clean_token($segments[0]);
return $id !== '' ? $id : '';
}
function media_imgur_page_url(string $url): string {
$path = media_imgur_path_from_url($url);
return $path !== '' ? 'https://imgur.com/' . $path : '';
}
function media_imgur_embed_url(string $url): string {
$page = media_imgur_page_url($url);
return $page !== '' ? rtrim($page, '/') . '/embed' : '';
}
function media_normalize_datetime(string $value): string { function media_normalize_datetime(string $value): string {
$value = trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8')); $value = trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($value === '') return ''; if ($value === '') return '';
@@ -638,6 +700,10 @@ function media_external_view_count_from_url(string $url): int {
if (media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube-nocookie.com')) { if (media_host_matches($host, 'youtube.com') || media_host_matches($host, 'youtu.be') || media_host_matches($host, 'youtube-nocookie.com')) {
return media_youtube_view_count($url); return media_youtube_view_count($url);
} }
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_page = media_imgur_page_url($url);
if ($imgur_page !== '') $url = $imgur_page;
}
$fetch = media_http_get($url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'); $fetch = media_http_get($url, 15, 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8');
if ($fetch['body'] === '' || $fetch['error'] !== '') return 0; if ($fetch['body'] === '' || $fetch['error'] !== '') return 0;
@@ -844,6 +910,10 @@ function media_provider_oembed_endpoint(string $url, string $host): string {
if (media_host_matches($host, 'dailymotion.com') || media_host_matches($host, 'dai.ly')) { if (media_host_matches($host, 'dailymotion.com') || media_host_matches($host, 'dai.ly')) {
return 'https://www.dailymotion.com/services/oembed?url=' . rawurlencode($url); return 'https://www.dailymotion.com/services/oembed?url=' . rawurlencode($url);
} }
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_page = media_imgur_page_url($url);
if ($imgur_page !== '') return 'https://api.imgur.com/oembed.json?url=' . rawurlencode($imgur_page);
}
if (media_host_matches($host, 'tiktok.com')) { if (media_host_matches($host, 'tiktok.com')) {
return 'https://www.tiktok.com/oembed?url=' . rawurlencode($url); return 'https://www.tiktok.com/oembed?url=' . rawurlencode($url);
} }
@@ -975,6 +1045,14 @@ function media_external_metadata(string $url): array {
if ($url === '') return $metadata; if ($url === '') return $metadata;
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? '')); $host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_page = media_imgur_page_url($url);
if ($imgur_page !== '') {
$url = $imgur_page;
$metadata['url'] = $url;
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
}
}
$provider = media_provider_embed($url); $provider = media_provider_embed($url);
$metadata['provider'] = $provider['provider'] ?? ''; $metadata['provider'] = $provider['provider'] ?? '';
@@ -1049,6 +1127,13 @@ function media_provider_thumbnail_url(string $url): string {
if ($id !== '') return 'https://www.dailymotion.com/thumbnail/video/' . rawurlencode($id); if ($id !== '') return 'https://www.dailymotion.com/thumbnail/video/' . rawurlencode($id);
} }
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$imgur_path = media_imgur_path_from_url($url);
if ($imgur_path !== '' && !str_contains($imgur_path, '/')) {
return 'https://i.imgur.com/' . rawurlencode($imgur_path) . 'h.jpg';
}
}
return media_oembed_thumbnail_url($url, $host); return media_oembed_thumbnail_url($url, $host);
} }
@@ -1184,6 +1269,11 @@ function media_provider_embed(string $url): ?array {
if ($id !== '') return ['provider' => 'dailymotion', 'embed' => 'https://www.dailymotion.com/embed/video/' . rawurlencode($id)]; if ($id !== '') return ['provider' => 'dailymotion', 'embed' => 'https://www.dailymotion.com/embed/video/' . rawurlencode($id)];
} }
if (media_host_matches($host, 'imgur.com') || media_host_matches($host, 'i.imgur.com')) {
$embed = media_imgur_embed_url($url);
if ($embed !== '') return ['provider' => 'imgur', 'embed' => $embed];
}
if (media_host_matches($host, 'twitch.tv')) { if (media_host_matches($host, 'twitch.tv')) {
$parent = media_twitch_parent_host(); $parent = media_twitch_parent_host();
if (preg_match('#/videos/(\d+)#', $path, $m)) { if (preg_match('#/videos/(\d+)#', $path, $m)) {
+60 -1
View File
@@ -33,6 +33,9 @@ $public_stats = $show_page_stats ? public_page_stats() : [
'sources' => 0, 'sources' => 0,
'views' => $total_public_views, 'views' => $total_public_views,
]; ];
$live_enabled = live_enabled();
$live_url = $live_enabled ? live_embed_url() : '';
$live_title = live_embed_title();
$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));
@@ -150,6 +153,24 @@ render_head('Media — TyClifford.com', '
display:block; width:100%; height:100%; display:block; width:100%; height:100%;
border:0; background:#000; border:0; background:#000;
} }
.home-mode-toggle {
display:inline-flex; align-items:center; gap:.4rem;
align-self:flex-start;
padding:.25rem; border:1px solid var(--border-2); border-radius:var(--r);
background:rgba(255,255,255,.03);
}
.home-mode-toggle button {
border:0; border-radius:calc(var(--r) - 1px); cursor:pointer;
padding:.38rem .75rem;
font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.08em;
color:var(--text-3); background:transparent;
transition:color .2s, background .2s;
}
.home-mode-toggle button:hover { color:var(--text-2); }
.home-mode-toggle button.active { color:var(--green); background:rgba(0,232,122,.12); }
.home-mode-panel[hidden] { display:none; }
.live-footer { justify-content:space-between; }
.live-footer .stream-meta { max-width:60ch; line-height:1.5; letter-spacing:.06em; }
/* ── Lower grid ── */ /* ── Lower grid ── */
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; } .lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
@@ -252,8 +273,30 @@ render_head('Media — TyClifford.com', '
<?php render_topbar('home'); ?> <?php render_topbar('home'); ?>
<?php if ($live_enabled): ?>
<div class="home-mode-toggle" role="tablist" aria-label="Homepage media mode">
<button type="button" class="active" data-home-mode="videos" role="tab" aria-selected="true" aria-controls="home-video-panel">Videos</button>
<button type="button" data-home-mode="live" role="tab" aria-selected="false" aria-controls="home-live-panel">Live</button>
</div>
<section class="stream-card home-mode-panel" id="home-live-panel" aria-label="Live stream" hidden>
<div class="stream-ratio">
<iframe class="provider-embed-frame"
src="<?= h($live_url) ?>"
allowfullscreen
allow="autoplay; fullscreen"
title="<?= h($live_title) ?>"
referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
<div class="stream-footer live-footer">
<p class="eyebrow eyebrow-green"><?= h($live_title) ?></p>
<p class="stream-meta"><span>live embed</span></p>
</div>
</section>
<?php endif; ?>
<!-- ═══════════════════════════ PLAYER CARD ════════════════════════════════ --> <!-- ═══════════════════════════ PLAYER CARD ════════════════════════════════ -->
<section class="stream-card" aria-label="Media player"> <section class="stream-card home-mode-panel" id="home-video-panel" aria-label="Media player">
<div class="stream-ratio"> <div class="stream-ratio">
<?php if ($primary_player_source && ($primary_player_source['kind'] ?? '') === 'iframe'): ?> <?php if ($primary_player_source && ($primary_player_source['kind'] ?? '') === 'iframe'): ?>
<iframe class="provider-embed-frame" <iframe class="provider-embed-frame"
@@ -502,6 +545,22 @@ document.addEventListener('DOMContentLoaded', function() {
toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
}); });
} }
var modeButtons = document.querySelectorAll('[data-home-mode]');
var livePanel = document.getElementById('home-live-panel');
var videoPanel = document.getElementById('home-video-panel');
modeButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var mode = btn.getAttribute('data-home-mode');
modeButtons.forEach(function(other) {
var active = other === btn;
other.classList.toggle('active', active);
other.setAttribute('aria-selected', active ? 'true' : 'false');
});
if (livePanel) livePanel.hidden = mode !== 'live';
if (videoPanel) videoPanel.hidden = mode === 'live';
});
});
}); });
// Copy embed code to clipboard // Copy embed code to clipboard