Files
mediaplayer/lone-embed.php
T
Ty Clifford 1c51cc0b30 - Lone Embed
2026-06-17 23:44:39 -04:00

1323 lines
40 KiB
PHP

<?php
declare(strict_types=1);
/**
* embed.php - Standalone embeddable player, URL based and database free.
*
* Accepted query parameters:
* video, url, src, v, slug Primary URL.
* video2-video10 Additional fallback URLs.
* url2-url10 Additional fallback URLs.
* title Optional display title.
* poster Optional poster/cover image URL.
* mode auto, video, audio, hls, dash, iframe.
* autoplay 1/0.
* muted 1/0.
* loop 1/0.
* controls 1/0, default 1.
* iframe_fallback 1/0, default 1.
* crossorigin anonymous or use-credentials.
* with_credentials 1/0, for HLS segment requests.
*
* MIME override:
* Append |mime/type to a source URL:
* embed.php?video=https%3A%2F%2Fexample.com%2Fstream%7Capplication%2Fx-mpegURL
*
* Important:
* URL-encode media URLs that contain their own ? or & characters.
*/
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function starts_with(string $value, string $prefix): bool
{
return $prefix === '' || strncmp($value, $prefix, strlen($prefix)) === 0;
}
function host_matches(string $host, string $domain): bool
{
$host = strtolower($host);
$domain = strtolower($domain);
return $host === $domain || substr($host, -strlen('.' . $domain)) === '.' . $domain;
}
function scalar_string($value): string
{
if (is_array($value)) {
return '';
}
return trim((string) $value);
}
function get_query_values(string $url): array
{
$query = parse_url($url, PHP_URL_QUERY);
if (!is_string($query) || $query === '') {
return [];
}
$values = [];
parse_str($query, $values);
return is_array($values) ? $values : [];
}
function path_segments(string $url): array
{
$path = parse_url($url, PHP_URL_PATH);
if (!is_string($path)) {
return [];
}
$parts = array_values(array_filter(explode('/', trim($path, '/')), 'strlen'));
return array_map('rawurldecode', $parts);
}
function extension_from_url(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
if (!is_string($path)) {
return '';
}
return strtolower(pathinfo($path, PATHINFO_EXTENSION));
}
function guess_mime(string $url): string
{
$map = [
'mp4' => 'video/mp4',
'm4v' => 'video/mp4',
'webm' => 'video/webm',
'ogv' => 'video/ogg',
'ogg' => 'video/ogg',
'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska',
'avi' => 'video/x-msvideo',
'wmv' => 'video/x-ms-wmv',
'flv' => 'video/x-flv',
'ts' => 'video/mp2t',
'3gp' => 'video/3gpp',
'3g2' => 'video/3gpp2',
'm3u8' => 'application/vnd.apple.mpegurl',
'mpd' => 'application/dash+xml',
'mp3' => 'audio/mpeg',
'm4a' => 'audio/mp4',
'aac' => 'audio/aac',
'wav' => 'audio/wav',
'flac' => 'audio/flac',
'oga' => 'audio/ogg',
'opus' => 'audio/ogg',
];
$ext = extension_from_url($url);
return $map[$ext] ?? '';
}
function classify_kind(string $url, string $mime, string $forced_mode): string
{
$forced = strtolower($forced_mode);
if (in_array($forced, ['video', 'audio', 'hls', 'dash', 'iframe'], true)) {
return $forced;
}
$ext = extension_from_url($url);
$mime_lc = strtolower($mime);
if ($ext === 'm3u8' || in_array($mime_lc, ['application/x-mpegurl', 'application/vnd.apple.mpegurl'], true)) {
return 'hls';
}
if ($ext === 'mpd' || $mime_lc === 'application/dash+xml') {
return 'dash';
}
if (starts_with($mime_lc, 'audio/')) {
return 'audio';
}
if (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';
}
return 'unknown';
}
function normalize_url(string $url): string
{
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($url === '') {
return '';
}
if (starts_with($url, '//')) {
$url = 'https:' . $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!is_string($scheme) || $scheme === '') {
return $url;
}
$scheme = strtolower($scheme);
if (!in_array($scheme, ['http', 'https'], true)) {
return '';
}
return $url;
}
function append_query(string $base, array $params): string
{
$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
if ($query === '') {
return $base;
}
return $base . (strpos($base, '?') === false ? '?' : '&') . $query;
}
function parse_youtube_time(string $value): int
{
$value = trim($value);
if ($value === '') {
return 0;
}
if (ctype_digit($value)) {
return max(0, (int) $value);
}
$seconds = 0;
if (preg_match_all('/(\d+)\s*([hms])/i', $value, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$amount = (int) $match[1];
$unit = strtolower($match[2]);
if ($unit === 'h') {
$seconds += $amount * 3600;
} elseif ($unit === 'm') {
$seconds += $amount * 60;
} else {
$seconds += $amount;
}
}
}
return max(0, $seconds);
}
function clean_token(string $value, string $pattern = '/[^A-Za-z0-9_-]/'): string
{
$clean = preg_replace($pattern, '', trim($value));
return is_string($clean) ? $clean : '';
}
function twitch_parent_host(): string
{
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
$host = strtolower(trim((string) $host));
$host = preg_replace('/:\d+$/', '', $host);
$host = is_string($host) ? $host : 'localhost';
return $host !== '' ? $host : 'localhost';
}
function provider_embed(string $url): ?array
{
$parts = @parse_url($url);
if (!is_array($parts) || empty($parts['host'])) {
return null;
}
$host = strtolower((string) $parts['host']);
$path = isset($parts['path']) ? (string) $parts['path'] : '';
$segments = path_segments($url);
$query = get_query_values($url);
if (host_matches($host, 'youtu.be') || host_matches($host, 'youtube.com') || host_matches($host, 'youtube-nocookie.com')) {
$id = '';
if (host_matches($host, 'youtu.be') && isset($segments[0])) {
$id = $segments[0];
}
if ($id === '' && isset($query['v'])) {
$id = scalar_string($query['v']);
}
if ($id === '' && preg_match('#/(?:embed|shorts|live|v)/([^/?#]+)#', $path, $m)) {
$id = $m[1];
}
$id = clean_token($id);
if ($id !== '') {
$params = ['rel' => '0', 'modestbranding' => '1', 'playsinline' => '1'];
$start = 0;
if (isset($query['start'])) {
$start = parse_youtube_time(scalar_string($query['start']));
} elseif (isset($query['t'])) {
$start = parse_youtube_time(scalar_string($query['t']));
}
if ($start > 0) {
$params['start'] = (string) $start;
}
return [
'provider' => 'youtube',
'embed' => append_query('https://www.youtube-nocookie.com/embed/' . rawurlencode($id), $params),
];
}
if (isset($query['list'])) {
$list = clean_token(scalar_string($query['list']));
if ($list !== '') {
return [
'provider' => 'youtube',
'embed' => append_query('https://www.youtube-nocookie.com/embed/videoseries', [
'list' => $list,
'rel' => '0',
'modestbranding' => '1',
'playsinline' => '1',
]),
];
}
}
}
if (host_matches($host, 'vimeo.com')) {
$id = '';
if (preg_match('#/(?:video/)?(\d+)#', $path, $m)) {
$id = $m[1];
}
if ($id !== '') {
return [
'provider' => 'vimeo',
'embed' => append_query('https://player.vimeo.com/video/' . rawurlencode($id), [
'title' => '0',
'byline' => '0',
'portrait' => '0',
]),
];
}
}
if (host_matches($host, 'dailymotion.com') || host_matches($host, 'dai.ly')) {
$id = '';
if (host_matches($host, 'dai.ly') && isset($segments[0])) {
$id = $segments[0];
}
if ($id === '' && preg_match('#/(?:embed/)?video/([^/?#_]+)#', $path, $m)) {
$id = $m[1];
}
$id = clean_token($id);
if ($id !== '') {
return [
'provider' => 'dailymotion',
'embed' => 'https://www.dailymotion.com/embed/video/' . rawurlencode($id),
];
}
}
if (host_matches($host, 'twitch.tv')) {
$parent = twitch_parent_host();
if (preg_match('#/videos/(\d+)#', $path, $m)) {
return [
'provider' => 'twitch',
'embed' => append_query('https://player.twitch.tv/', [
'video' => $m[1],
'parent' => $parent,
'autoplay' => 'false',
]),
];
}
if (preg_match('#/([^/]+)/clip/([^/?#]+)#', $path, $m)) {
return [
'provider' => 'twitch',
'embed' => append_query('https://clips.twitch.tv/embed', [
'clip' => clean_token($m[2]),
'parent' => $parent,
'autoplay' => 'false',
]),
];
}
if (isset($segments[0]) && $segments[0] !== '') {
return [
'provider' => 'twitch',
'embed' => append_query('https://player.twitch.tv/', [
'channel' => clean_token($segments[0]),
'parent' => $parent,
'autoplay' => 'false',
]),
];
}
}
if (host_matches($host, 'clips.twitch.tv') && isset($segments[0])) {
return [
'provider' => 'twitch',
'embed' => append_query('https://clips.twitch.tv/embed', [
'clip' => clean_token($segments[0]),
'parent' => twitch_parent_host(),
'autoplay' => 'false',
]),
];
}
if (host_matches($host, 'facebook.com') || host_matches($host, 'fb.watch')) {
return [
'provider' => 'facebook',
'embed' => append_query('https://www.facebook.com/plugins/video.php', [
'href' => $url,
'show_text' => 'false',
'width' => '1280',
]),
];
}
if (host_matches($host, 'tiktok.com') && preg_match('#/video/(\d+)#', $path, $m)) {
return [
'provider' => 'tiktok',
'embed' => 'https://www.tiktok.com/embed/v2/' . rawurlencode($m[1]),
];
}
if (host_matches($host, 'instagram.com') && preg_match('#/(p|reel|tv)/([^/?#]+)#', $path, $m)) {
return [
'provider' => 'instagram',
'embed' => 'https://www.instagram.com/' . rawurlencode($m[1]) . '/' . rawurlencode($m[2]) . '/embed',
];
}
if ((host_matches($host, 'twitter.com') || host_matches($host, 'x.com')) && preg_match('#/status/(\d+)#', $path, $m)) {
return [
'provider' => 'twitter',
'embed' => append_query('https://platform.twitter.com/embed/Tweet.html', ['id' => $m[1]]),
];
}
if (host_matches($host, 'soundcloud.com')) {
return [
'provider' => 'soundcloud',
'embed' => append_query('https://w.soundcloud.com/player/', [
'url' => $url,
'auto_play' => 'false',
'show_teaser' => 'true',
]),
];
}
if (host_matches($host, 'open.spotify.com') && isset($segments[0], $segments[1])) {
$type = clean_token($segments[0]);
$id = clean_token($segments[1]);
if (in_array($type, ['album', 'artist', 'episode', 'playlist', 'show', 'track'], true) && $id !== '') {
return [
'provider' => 'spotify',
'embed' => 'https://open.spotify.com/embed/' . rawurlencode($type) . '/' . rawurlencode($id),
];
}
}
if (host_matches($host, 'drive.google.com')) {
$id = '';
if (preg_match('#/file/d/([^/]+)#', $path, $m)) {
$id = $m[1];
} elseif (isset($query['id'])) {
$id = scalar_string($query['id']);
}
$id = clean_token($id);
if ($id !== '') {
return [
'provider' => 'google-drive',
'embed' => 'https://drive.google.com/file/d/' . rawurlencode($id) . '/preview',
];
}
}
if ((host_matches($host, 'wistia.com') || host_matches($host, 'wi.st')) && preg_match('#/(?:medias/)?([A-Za-z0-9]+)#', $path, $m)) {
return [
'provider' => 'wistia',
'embed' => 'https://fast.wistia.net/embed/iframe/' . rawurlencode($m[1]),
];
}
if (host_matches($host, 'streamable.com') && isset($segments[0])) {
return [
'provider' => 'streamable',
'embed' => 'https://streamable.com/e/' . rawurlencode(clean_token($segments[0])),
];
}
if (host_matches($host, 'loom.com') && isset($segments[0], $segments[1]) && $segments[0] === 'share') {
return [
'provider' => 'loom',
'embed' => 'https://www.loom.com/embed/' . rawurlencode(clean_token($segments[1])),
];
}
return null;
}
function rewrite_direct_url(string $url): string
{
$host = strtolower((string) (parse_url($url, PHP_URL_HOST) ?? ''));
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '');
if (host_matches($host, 'dropbox.com')) {
$parts = @parse_url($url);
if (is_array($parts)) {
$query = [];
if (isset($parts['query'])) {
parse_str((string) $parts['query'], $query);
}
unset($query['dl'], $query['raw']);
$query['raw'] = '1';
$rebuilt = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? 'www.dropbox.com');
$rebuilt .= $parts['path'] ?? '';
$rebuilt = append_query($rebuilt, $query);
if (isset($parts['fragment'])) {
$rebuilt .= '#' . $parts['fragment'];
}
return $rebuilt;
}
}
if (host_matches($host, 'github.com') && preg_match('#^/([^/]+)/([^/]+)/blob/([^/]+)/(.+)$#', $path, $m)) {
return 'https://raw.githubusercontent.com/' . rawurlencode($m[1]) . '/' . rawurlencode($m[2]) . '/' . rawurlencode($m[3]) . '/' . str_replace('%2F', '/', rawurlencode($m[4]));
}
return $url;
}
function source_from_raw(string $raw, string $forced_mode): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$mime = '';
if (strpos($raw, '|') !== false) {
[$raw, $mime] = explode('|', $raw, 2);
$mime = trim($mime);
}
$original_url = normalize_url($raw);
if ($original_url === '') {
return null;
}
$provider = provider_embed($original_url);
$url = $provider ? $original_url : rewrite_direct_url($original_url);
if ($mime === '') {
$mime = guess_mime($url);
}
$kind = classify_kind($url, $mime, $forced_mode);
if ($provider && strtolower($forced_mode) === 'auto') {
$kind = 'iframe';
}
if (strtolower($forced_mode) === 'iframe') {
$kind = 'iframe';
}
$ext = extension_from_url($url);
$label = $provider['provider'] ?? ($ext !== '' ? $ext : ($kind === 'unknown' ? 'url' : $kind));
return [
'url' => $url,
'originalUrl' => $original_url,
'embed' => $provider['embed'] ?? $url,
'provider' => $provider['provider'] ?? '',
'mime' => $mime,
'kind' => $kind,
'label' => $label,
];
}
function query_bool(string $name, bool $default = false): bool
{
if (!array_key_exists($name, $_GET)) {
return $default;
}
$value = strtolower(trim((string) $_GET[$name]));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($value, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $default;
}
function media_type_attr(array $source): string
{
$mime = isset($source['mime']) ? trim((string) $source['mime']) : '';
return $mime !== '' ? ' type="' . h($mime) . '"' : '';
}
function media_common_attrs(array $config): string
{
$attrs = [];
if (($config['controls'] ?? true) !== false) {
$attrs[] = 'controls';
}
if (($config['autoplay'] ?? false) === true) {
$attrs[] = 'autoplay';
}
if (($config['muted'] ?? false) === true || ($config['autoplay'] ?? false) === true) {
$attrs[] = 'muted';
}
if (($config['loop'] ?? false) === true) {
$attrs[] = 'loop';
}
$attrs[] = 'preload="metadata"';
$crossorigin = trim((string) ($config['crossorigin'] ?? ''));
if ($crossorigin !== '') {
$attrs[] = 'crossorigin="' . h($crossorigin) . '"';
}
return implode(' ', $attrs);
}
$title = trim((string) ($_GET['title'] ?? ''));
$poster = normalize_url((string) ($_GET['poster'] ?? ''));
$mode = strtolower(trim((string) ($_GET['mode'] ?? 'auto')));
if (!in_array($mode, ['auto', 'video', 'audio', 'hls', 'dash', 'iframe'], true)) {
$mode = 'auto';
}
$primary_raw = '';
foreach (['video', 'url', 'src', 'v', 'slug'] as $key) {
if (isset($_GET[$key]) && trim((string) $_GET[$key]) !== '') {
$primary_raw = trim((string) $_GET[$key]);
break;
}
}
$sources = [];
if ($primary_raw !== '') {
$source = source_from_raw($primary_raw, $mode);
if ($source !== null) {
$sources[] = $source;
}
}
for ($i = 2; $i <= 10; $i++) {
foreach (["video{$i}", "url{$i}", "src{$i}"] as $key) {
if (isset($_GET[$key]) && trim((string) $_GET[$key]) !== '') {
$source = source_from_raw(trim((string) $_GET[$key]), $mode);
if ($source !== null) {
$sources[] = $source;
}
break;
}
}
}
$crossorigin = strtolower(trim((string) ($_GET['crossorigin'] ?? '')));
if (!in_array($crossorigin, ['anonymous', 'use-credentials'], true)) {
$crossorigin = '';
}
$config = [
'sources' => $sources,
'title' => $title,
'poster' => $poster,
'autoplay' => query_bool('autoplay', false),
'muted' => query_bool('muted', false),
'loop' => query_bool('loop', false),
'controls' => query_bool('controls', true),
'playsinline' => true,
'iframeFallback' => query_bool('iframe_fallback', true),
'crossorigin' => $crossorigin,
'withCredentials' => query_bool('with_credentials', false),
];
$primary_source = $sources[0] ?? null;
$json_config = json_encode(
$config,
JSON_UNESCAPED_SLASHES
| JSON_UNESCAPED_UNICODE
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_HEX_QUOT
);
if (!is_string($json_config)) {
$json_config = '{"sources":[]}';
}
header_remove('X-Frame-Options');
header_remove('Content-Security-Policy');
header_remove('X-Content-Security-Policy');
header('Content-Type: text/html; charset=utf-8');
header("Content-Security-Policy: default-src * data: blob: 'unsafe-inline' 'unsafe-eval'; script-src * data: blob: 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'; img-src * data: blob:; media-src * data: blob:; frame-src *; connect-src *");
header('Referrer-Policy: no-referrer-when-downgrade');
header('Cross-Origin-Resource-Policy: cross-origin');
header('X-Content-Type-Options: nosniff');
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer-when-downgrade">
<title><?= $title !== '' ? h($title) . ' - Player' : 'Player' ?></title>
<script defer src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/dashjs@4/dist/dash.all.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; }
body {
overflow: hidden;
background: #05060a;
color: #f4f7fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.player {
position: relative;
width: 100%;
height: 100%;
min-height: 160px;
background: #000;
isolation: isolate;
}
.accent {
position: absolute;
z-index: 8;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #ff3f5f, #9d65ff, #2ccfff, #19e68c, #2ccfff, #9d65ff, #ff3f5f);
background-size: 300% 100%;
animation: shimmer 5s linear infinite;
}
@keyframes shimmer {
from { background-position: 0 50%; }
to { background-position: 200% 50%; }
}
.top {
position: absolute;
z-index: 7;
top: 10px;
left: 12px;
right: 12px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
pointer-events: none;
opacity: 1;
transition: opacity 180ms ease;
}
.top.is-hidden { opacity: 0; }
.title {
min-width: 0;
overflow: hidden;
color: #fff;
font-size: 13px;
font-weight: 650;
line-height: 1.25;
text-overflow: ellipsis;
text-shadow: 0 1px 8px rgba(0,0,0,.8);
white-space: nowrap;
}
.badges {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
max-width: 45%;
}
.badge {
border: 1px solid rgba(255,255,255,.14);
border-radius: 4px;
background: rgba(0,0,0,.48);
color: rgba(255,255,255,.62);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 10px;
letter-spacing: .06em;
line-height: 1;
padding: 4px 6px;
text-transform: uppercase;
white-space: nowrap;
}
.mount,
.frame-mount,
.media-mount {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.native-media,
.embed-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #000;
}
.native-video { object-fit: contain; }
.audio-layout {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 28px;
background:
radial-gradient(circle at 25% 20%, rgba(25,230,140,.2), transparent 26%),
radial-gradient(circle at 80% 75%, rgba(157,101,255,.18), transparent 24%),
#080910;
}
.audio-card {
width: min(760px, 100%);
display: grid;
grid-template-columns: minmax(96px, 180px) minmax(0, 1fr);
gap: 18px;
align-items: center;
}
.cover {
aspect-ratio: 1;
border-radius: 8px;
background: linear-gradient(135deg, #141827, #243347);
background-position: center;
background-size: cover;
box-shadow: 0 18px 55px rgba(0,0,0,.35);
}
.native-audio { height: 54px; background: transparent; }
.status {
position: absolute;
z-index: 9;
left: 12px;
bottom: 12px;
max-width: calc(100% - 24px);
border: 1px solid rgba(255,255,255,.12);
border-radius: 5px;
background: rgba(5,6,10,.72);
color: rgba(255,255,255,.72);
font-size: 12px;
line-height: 1.35;
padding: 7px 9px;
backdrop-filter: blur(10px);
}
.error,
.empty {
position: absolute;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: 22px;
background: #080910;
text-align: center;
}
.panel {
width: min(560px, 100%);
display: grid;
gap: 10px;
justify-items: center;
}
.panel h1 {
margin: 0;
color: #fff;
font-size: 16px;
line-height: 1.25;
}
.panel p {
margin: 0;
color: rgba(255,255,255,.62);
font-size: 13px;
line-height: 1.55;
}
.panel a {
color: #7ee7ff;
font-size: 13px;
text-decoration: none;
}
.panel code {
color: rgba(255,255,255,.86);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
overflow-wrap: anywhere;
}
.is-hidden { display: none !important; }
@media (max-width: 520px) {
.top { top: 8px; left: 8px; right: 8px; }
.title { font-size: 12px; }
.badges { max-width: 38%; }
.audio-card { grid-template-columns: 1fr; }
.cover { width: min(180px, 60vw); justify-self: center; }
}
</style>
</head>
<body>
<main class="player" id="player" aria-label="Embedded media player">
<div class="accent"></div>
<?php if (count($sources) > 0): ?>
<div class="top" id="top-overlay">
<div class="title"><?= $title !== '' ? h($title) : '' ?></div>
<div class="badges" aria-hidden="true">
<?php foreach ($sources as $source): ?>
<span class="badge"><?= h((string) $source['label']) ?></span>
<?php endforeach; ?>
</div>
</div>
<div class="mount">
<?php
$primary_kind = is_array($primary_source) ? (string) ($primary_source['kind'] ?? 'video') : 'video';
$show_frame = is_array($primary_source) && $primary_kind === 'iframe';
?>
<div class="media-mount<?= $show_frame ? ' is-hidden' : '' ?>" id="media-mount">
<?php if (is_array($primary_source) && !$show_frame): ?>
<?php if ($primary_kind === 'audio'): ?>
<div class="audio-layout">
<div class="audio-card">
<div class="cover"<?php if ($poster !== ''): ?> style="background-image:url('<?= h($poster) ?>')"<?php endif; ?>></div>
<audio class="native-media native-audio" <?= media_common_attrs($config) ?>>
<source src="<?= h((string) $primary_source['url']) ?>"<?= media_type_attr($primary_source) ?>>
</audio>
</div>
</div>
<?php else: ?>
<video class="native-media native-video"
<?= media_common_attrs($config) ?>
playsinline
<?php if ($poster !== ''): ?>poster="<?= h($poster) ?>"<?php endif; ?>>
<source src="<?= h((string) $primary_source['url']) ?>"<?= media_type_attr($primary_source) ?>>
</video>
<?php endif; ?>
<?php endif; ?>
</div>
<div class="frame-mount<?= $show_frame ? '' : ' is-hidden' ?>" id="frame-mount">
<?php if (is_array($primary_source) && $show_frame): ?>
<iframe class="embed-frame"
src="<?= h((string) ($primary_source['embed'] ?? $primary_source['url'])) ?>"
title="<?= h($title !== '' ? $title : (string) ($primary_source['label'] ?? 'Embedded media')) ?>"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"></iframe>
<?php endif; ?>
</div>
</div>
<noscript>
<div class="status">JavaScript is disabled. Direct files and supported provider embeds can still load; HLS and DASH may need JavaScript.</div>
</noscript>
<div class="status is-hidden" id="status"></div>
<div class="error is-hidden" id="error-panel">
<div class="panel">
<h1 id="error-title">Could not play this URL</h1>
<p id="error-message"></p>
<a id="error-link" href="#" target="_blank" rel="noopener">Open source URL</a>
</div>
</div>
<?php else: ?>
<div class="empty">
<div class="panel">
<h1>No media URL supplied</h1>
<p>Use <code>?video=https%3A%2F%2Fexample.com%2Fclip.mp4</code> or <code>?url=...</code>.</p>
</div>
</div>
<?php endif; ?>
</main>
<?php if (count($sources) > 0): ?>
<script>
window.addEventListener('DOMContentLoaded', function () {
'use strict';
const config = <?= $json_config ?>;
const sources = Array.isArray(config.sources) ? config.sources : [];
const mediaMount = document.getElementById('media-mount');
const frameMount = document.getElementById('frame-mount');
const statusBox = document.getElementById('status');
const errorPanel = document.getElementById('error-panel');
const errorMessage = document.getElementById('error-message');
const errorLink = document.getElementById('error-link');
const topOverlay = document.getElementById('top-overlay');
let hlsEngine = null;
let dashEngine = null;
let currentIndex = -1;
let attemptedGenericFrame = false;
const failures = new Map();
function setStatus(message) {
if (!statusBox) return;
if (!message) {
statusBox.textContent = '';
statusBox.classList.add('is-hidden');
return;
}
statusBox.textContent = message;
statusBox.classList.remove('is-hidden');
}
function hideError() {
if (errorPanel) errorPanel.classList.add('is-hidden');
}
function destroyEngines() {
if (hlsEngine) {
try { hlsEngine.destroy(); } catch (e) {}
hlsEngine = null;
}
if (dashEngine) {
try { dashEngine.reset(); } catch (e) {}
dashEngine = null;
}
}
function clearMounts() {
destroyEngines();
if (mediaMount) mediaMount.innerHTML = '';
if (frameMount) frameMount.innerHTML = '';
if (mediaMount) mediaMount.classList.remove('is-hidden');
if (frameMount) frameMount.classList.add('is-hidden');
}
function labelFor(source) {
return (source && (source.provider || source.label || source.kind)) || 'URL';
}
function mediaErrorMessage(error) {
if (!error) return 'The browser rejected the media source.';
const map = {
1: 'Playback was aborted.',
2: 'A network error stopped the download.',
3: 'The media is damaged or uses an unsupported codec.',
4: 'No compatible media source was found.'
};
return map[error.code] || 'The browser rejected the media source.';
}
function showFailure(reason) {
const first = sources[0] || {};
clearMounts();
setStatus('');
if (errorMessage) {
errorMessage.textContent = (reason || 'No compatible media source was found.') +
' Browser playback still depends on supported codecs, server headers, CORS for HLS/DASH, and whether a provider allows embedding.';
}
if (errorLink && first.originalUrl) {
errorLink.href = first.originalUrl;
errorLink.classList.remove('is-hidden');
} else if (errorLink) {
errorLink.classList.add('is-hidden');
}
if (errorPanel) errorPanel.classList.remove('is-hidden');
}
function nextSourceIndex(afterIndex) {
for (let step = 1; step <= sources.length; step += 1) {
const index = (afterIndex + step) % sources.length;
if (!failures.has(index)) return index;
}
return -1;
}
function failSource(index, reason) {
failures.set(index, reason || 'This source failed.');
const next = nextSourceIndex(index);
if (next >= 0 && next !== index) {
setStatus('Trying fallback source: ' + labelFor(sources[next]) + '.');
mountSource(next);
return;
}
const first = sources[0];
if (config.iframeFallback && first && first.originalUrl && !attemptedGenericFrame) {
attemptedGenericFrame = true;
setStatus('Trying the URL as an embedded webpage. If it stays blank, the site blocks iframes.');
mountIframe(first.originalUrl, first, true);
return;
}
showFailure(reason);
}
function makeMediaElement(kind) {
const media = document.createElement(kind === 'audio' ? 'audio' : 'video');
media.className = kind === 'audio' ? 'native-media native-audio' : 'native-media native-video';
media.controls = config.controls !== false;
media.autoplay = config.autoplay === true;
media.muted = config.muted === true;
media.loop = config.loop === true;
media.preload = 'metadata';
if (config.crossorigin) {
media.crossOrigin = config.crossorigin;
}
if (kind !== 'audio') {
media.playsInline = true;
media.setAttribute('playsinline', '');
if (config.poster) media.poster = config.poster;
}
return media;
}
function attachCommonMediaEvents(media, index) {
media.addEventListener('loadedmetadata', function () {
setStatus('');
}, { once: true });
media.addEventListener('canplay', function () {
setStatus('');
}, { once: true });
media.addEventListener('play', function () {
if (topOverlay) topOverlay.classList.add('is-hidden');
});
media.addEventListener('pause', function () {
if (topOverlay) topOverlay.classList.remove('is-hidden');
});
media.addEventListener('ended', function () {
if (topOverlay) topOverlay.classList.remove('is-hidden');
});
media.addEventListener('error', function () {
failSource(index, mediaErrorMessage(media.error));
}, { once: true });
}
function appendAudioLayout(media) {
const layout = document.createElement('div');
layout.className = 'audio-layout';
const card = document.createElement('div');
card.className = 'audio-card';
const cover = document.createElement('div');
cover.className = 'cover';
if (config.poster) {
cover.style.backgroundImage = 'url("' + String(config.poster).replace(/"/g, '%22') + '")';
}
card.appendChild(cover);
card.appendChild(media);
layout.appendChild(card);
mediaMount.appendChild(layout);
}
function loadAndMaybePlay(media) {
try { media.load(); } catch (e) {}
if (config.autoplay) {
const playPromise = media.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {});
}
}
}
function mountDirect(source, index, kind) {
clearMounts();
hideError();
const media = makeMediaElement(kind);
attachCommonMediaEvents(media, index);
if (kind === 'audio') {
appendAudioLayout(media);
} else {
mediaMount.appendChild(media);
}
media.src = source.url;
setStatus('Loading ' + labelFor(source) + '.');
loadAndMaybePlay(media);
}
function mountHls(source, index) {
clearMounts();
hideError();
const media = makeMediaElement('video');
attachCommonMediaEvents(media, index);
mediaMount.appendChild(media);
setStatus('Loading HLS stream.');
if (media.canPlayType('application/vnd.apple.mpegurl') || media.canPlayType('application/x-mpegURL')) {
media.src = source.url;
loadAndMaybePlay(media);
return;
}
if (!window.Hls || !window.Hls.isSupported()) {
failSource(index, 'HLS is not supported by this browser and hls.js is unavailable.');
return;
}
let recoveries = 0;
hlsEngine = new window.Hls({
enableWorker: true,
lowLatencyMode: true,
xhrSetup: function (xhr) {
if (config.withCredentials) xhr.withCredentials = true;
}
});
hlsEngine.on(window.Hls.Events.MANIFEST_PARSED, function () {
setStatus('');
if (config.autoplay) {
const playPromise = media.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {});
}
}
});
hlsEngine.on(window.Hls.Events.ERROR, function (event, data) {
if (!data || !data.fatal) return;
if (recoveries < 2 && data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
recoveries += 1;
hlsEngine.startLoad();
return;
}
if (recoveries < 2 && data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
recoveries += 1;
hlsEngine.recoverMediaError();
return;
}
failSource(index, 'The HLS stream failed. The server may not allow CORS for manifests or segments.');
});
hlsEngine.loadSource(source.url);
hlsEngine.attachMedia(media);
}
function mountDash(source, index) {
clearMounts();
hideError();
if (!window.dashjs || !window.dashjs.MediaPlayer) {
failSource(index, 'DASH playback is unavailable because dash.js did not load.');
return;
}
const media = makeMediaElement('video');
attachCommonMediaEvents(media, index);
mediaMount.appendChild(media);
setStatus('Loading DASH stream.');
dashEngine = window.dashjs.MediaPlayer().create();
if (dashEngine.updateSettings) {
dashEngine.updateSettings({
debug: { logLevel: 0 },
streaming: {
retryAttempts: { MPD: 2, MediaSegment: 2 },
retryIntervals: { MPD: 1000, MediaSegment: 1000 }
}
});
}
if (dashEngine.on && window.dashjs.MediaPlayer.events) {
dashEngine.on(window.dashjs.MediaPlayer.events.ERROR, function () {
failSource(index, 'The DASH stream failed. The server may not allow CORS or the manifest may be unsupported.');
});
}
dashEngine.initialize(media, source.url, config.autoplay === true);
}
function mountIframe(url, source, genericFallback) {
clearMounts();
hideError();
if (mediaMount) mediaMount.classList.add('is-hidden');
if (frameMount) frameMount.classList.remove('is-hidden');
const iframe = document.createElement('iframe');
iframe.className = 'embed-frame';
iframe.src = url;
iframe.title = config.title || labelFor(source) || 'Embedded media';
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share';
iframe.allowFullscreen = true;
iframe.referrerPolicy = 'no-referrer-when-downgrade';
frameMount.appendChild(iframe);
if (genericFallback) {
setStatus('Showing the URL as a webpage. If it is blank, that site blocks embedding.');
} else {
setStatus('');
}
}
function mountSource(index) {
currentIndex = index;
const source = sources[index];
if (!source) {
showFailure('No source was available.');
return;
}
const kind = source.kind || 'unknown';
if (kind === 'iframe') {
mountIframe(source.embed || source.url, source, false);
} else if (kind === 'hls') {
mountHls(source, index);
} else if (kind === 'dash') {
mountDash(source, index);
} else if (kind === 'audio') {
mountDirect(source, index, 'audio');
} else {
mountDirect(source, index, 'video');
}
}
if (sources.length === 0) {
showFailure('No media URL was supplied.');
return;
}
mountSource(0);
window.embedPlayer = {
playSource: function (index) {
const next = Number(index);
if (Number.isInteger(next) && next >= 0 && next < sources.length) {
failures.delete(next);
mountSource(next);
}
},
retry: function () {
failures.clear();
attemptedGenericFrame = false;
mountSource(currentIndex >= 0 ? currentIndex : 0);
},
sources: sources
};
});
</script>
<?php endif; ?>
</body>
</html>