• File: media.php
  • Full Path: /home/daemondevin/public_html/blog/theme/media.php
  • Date Modified: 10/17/2026 7:00 PM
  • File size: 9.5 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?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'] ?? []));
}