<?php
defined('TS_ROOT') or exit;
// Entry formats and media.
//
// A post has a format that says what sits above its text:
// standard the cover image (or nothing)
// slider several pictures in a carousel
// gallery several pictures as a grid that opens in a lightbox
// video YouTube, Vimeo, or a video file
// audio SoundCloud, Spotify, or an audio file
// embed CodePen (and the other supported players)
//
// Only addresses that resolve to a known player or a plain media file are accepted, so a post can never
// carry an arbitrary iframe. The result is always built here, never copied from what the author typed.
final class TSMedia
{
public const FORMATS = [
'standard' => 'Standard (cover image)',
'slider' => 'Slider (several pictures)',
'gallery' => 'Gallery (pictures with a lightbox)',
'video' => 'Video',
'audio' => 'Audio',
'embed' => 'Embed (CodePen and similar)',
];
public const MAX_ITEMS = 40;
// Hosts a page may frame. Used for the Content-Security-Policy frame-src directive.
public const FRAME_HOSTS = [
'https://www.youtube-nocookie.com', 'https://player.vimeo.com', 'https://codepen.io',
'https://w.soundcloud.com', 'https://open.spotify.com',
];
public static function format($f): string
{
$f = is_string($f) ? strtolower(trim($f)) : '';
return isset(self::FORMATS[$f]) ? $f : 'standard';
}
public static function isImages(string $format): bool { return $format === 'slider' || $format === 'gallery'; }
// Accepts a newline or comma separated string, a JSON list, or an array
public static function parseList($v): array
{
if (is_string($v)) {
$t = trim($v);
if ($t !== '' && $t[0] === '[') {
$d = json_decode($t, true);
if (is_array($d)) $v = $d;
}
}
if (is_string($v)) $v = preg_split('/\R+/', $v);
$out = [];
foreach ((array) $v as $item) {
$u = trim((string) $item);
if ($u !== '' && !in_array($u, $out, true)) $out[] = $u;
if (count($out) >= self::MAX_ITEMS) break;
}
return $out;
}
// Validates the media of a post for its format. Returns [list, errors].
public static function clean(string $format, array $list): array
{
$errors = [];
if ($format === 'standard') return [[], $errors];
if (self::isImages($format)) {
$keep = [];
foreach ($list as $u) {
if (TSMarkdown::safeUrl($u, true) !== '') $keep[] = $u;
else $errors[] = 'This picture address is not valid: ' . mb_substr($u, 0, 60);
}
return [$keep, $errors];
}
$list = array_slice($list, 0, 1);
if ($list && self::resolve($format, $list) === null) {
$errors[] = 'That address is not supported. Use a YouTube, Vimeo, CodePen, SoundCloud or Spotify link, or a link to an mp4, webm, mp3 or ogg file.';
}
return [$list, $errors];
}
// What to show for a post: ['kind' => images|iframe|video|audio, ...] or null
public static function resolve(string $format, array $list): ?array
{
if ($format === 'standard' || !$list) return null;
if (self::isImages($format)) {
$items = array_values(array_filter($list, fn($u) => TSMarkdown::safeUrl($u, true) !== ''));
return $items ? ['kind' => 'images', 'items' => $items, 'mode' => $format] : null;
}
$u = trim((string) $list[0]);
return self::player($u) ?? self::file($u);
}
// First picture of a post, for social cards and thumbnails
public static function firstImage(array $post): string
{
if (($post['image'] ?? '') !== '') return $post['image'];
$r = self::resolve(self::format($post['format'] ?? ''), (array) ($post['media'] ?? []));
return $r && $r['kind'] === 'images' ? $r['items'][0] : '';
}
private static function host(string $u): array
{
$p = @parse_url($u);
if (!$p || !in_array(strtolower($p['scheme'] ?? ''), ['http', 'https'], true) || empty($p['host'])) return ['', '', []];
parse_str($p['query'] ?? '', $q);
return [preg_replace('/^(www|m)\./', '', strtolower($p['host'])), $p['path'] ?? '', $q];
}
private static function player(string $u): ?array
{
[$h, $path, $q] = self::host($u);
if ($h === '') return null;
if (in_array($h, ['youtube.com', 'youtube-nocookie.com', 'youtu.be'], true)) {
$id = '';
if ($h === 'youtu.be') $id = trim($path, '/');
elseif (preg_match('#^/(?:embed|shorts|live|v)/([\w-]{11})#', $path, $m)) $id = $m[1];
elseif (($q['v'] ?? '') !== '') $id = (string) $q['v'];
if (!preg_match('/^[\w-]{11}$/', $id)) return null;
return ['kind' => 'iframe', 'provider' => 'youtube', 'ratio' => '16x9', 'title' => 'YouTube video player',
'src' => 'https://www.youtube-nocookie.com/embed/' . $id,
'allow' => 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', 'fullscreen' => true];
}
if ($h === 'vimeo.com' || $h === 'player.vimeo.com') {
if (!preg_match('#^/(?:video/|[\w/]*?)(\d{5,12})(?:/|$)#', $path, $m)) return null;
return ['kind' => 'iframe', 'provider' => 'vimeo', 'ratio' => '16x9', 'title' => 'Vimeo video player',
'src' => 'https://player.vimeo.com/video/' . $m[1], 'allow' => 'autoplay; fullscreen; picture-in-picture', 'fullscreen' => true];
}
if ($h === 'codepen.io') {
if (!preg_match('#^/([\w-]{1,40})/(?:pen|embed|full|details)/([A-Za-z0-9]{3,12})#', $path, $m)) return null;
return ['kind' => 'iframe', 'provider' => 'codepen', 'ratio' => '16x9', 'title' => 'CodePen: ' . $m[2],
'src' => 'https://codepen.io/' . $m[1] . '/embed/' . $m[2] . '?default-tab=result', 'allow' => '', 'fullscreen' => false];
}
if ($h === 'soundcloud.com' || $h === 'w.soundcloud.com' || $h === 'api.soundcloud.com') {
$page = $u;
if ($h === 'w.soundcloud.com') {
$page = (string) ($q['url'] ?? '');
[$ph] = self::host($page);
if (!in_array($ph, ['soundcloud.com', 'api.soundcloud.com'], true)) return null;
} elseif (trim($path, '/') === '') {
return null;
}
$page = preg_replace('/[?#].*$/', '', $page);
return ['kind' => 'iframe', 'provider' => 'soundcloud', 'ratio' => 'soundcloud', 'title' => 'SoundCloud player',
'src' => 'https://w.soundcloud.com/player/?url=' . rawurlencode($page) . '&color=%23ff5500&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true',
'allow' => '', 'fullscreen' => false];
}
if ($h === 'open.spotify.com') {
if (!preg_match('#^/(?:embed/)?(track|album|playlist|episode|show|artist)/([A-Za-z0-9]{10,30})#', $path, $m)) return null;
return ['kind' => 'iframe', 'provider' => 'spotify', 'ratio' => 'spotify', 'title' => 'Spotify player',
'src' => 'https://open.spotify.com/embed/' . $m[1] . '/' . $m[2], 'allow' => 'encrypted-media', 'fullscreen' => false];
}
return null;
}
private static function file(string $u): ?array
{
if ($u === '' || preg_match('/[\x00-\x1f\x7f\s]/', $u)) return null;
$isAbs = preg_match('#^https?://#i', $u);
if (!$isAbs && ($u[0] !== '/' || (isset($u[1]) && $u[1] === '/'))) return null;
$path = strtolower((string) parse_url($u, PHP_URL_PATH));
if (preg_match('/\.(mp4|webm|ogv|m4v)$/', $path)) return ['kind' => 'video', 'src' => $u];
if (preg_match('/\.(mp3|ogg|oga|wav|m4a|aac|flac)$/', $path)) return ['kind' => 'audio', 'src' => $u];
return null;
}
// The iframe or media element for a resolved player, with classes the theme styles
public static function html(array $r, string $class = 'tsm'): string
{
switch ($r['kind']) {
case 'iframe':
return '<div class="' . $class . ' ' . $class . '-frame ' . $class . '-' . e($r['ratio']) . '"><iframe src="' . e($r['src']) . '" title="' . e($r['title']) . '" loading="lazy"'
. ($r['allow'] !== '' ? ' allow="' . e($r['allow']) . '"' : '') . ($r['fullscreen'] ? ' allowfullscreen' : '')
. ' referrerpolicy="strict-origin-when-cross-origin"></iframe></div>';
case 'video':
return '<div class="' . $class . ' ' . $class . '-frame ' . $class . '-16x9"><video src="' . e($r['src']) . '" controls preload="metadata"></video></div>';
case 'audio':
return '<div class="' . $class . ' ' . $class . '-audio"><audio src="' . e($r['src']) . '" controls preload="none"></audio></div>';
case 'images':
$out = '<div class="' . $class . ' ' . $class . '-images">';
foreach ($r['items'] as $u) $out .= '<a href="' . e($u) . '"><img src="' . e($u) . '" alt="" loading="lazy"></a>';
return $out . '</div>';
}
return '';
}
}
// Convenience for templates: the media of a post as simple markup (themes may build their own from TSMedia::resolve)
function ts_media(array $post): ?array
{
return TSMedia::resolve(TSMedia::format($post['format'] ?? ''), (array) ($post['media'] ?? []));
}