From d5bb0bb475fdb4f58991aed155f01c96f8c6448b Mon Sep 17 00:00:00 2001 From: flashwave Date: Wed, 25 Jan 2023 22:33:59 +0000 Subject: [PATCH] Complete revamp of the forum video bbcode. Rather than blindly embedding everything, video metadata is first requested through the URL metadata lookup service. This slightly protects you from automatically connecting to third party servers and also vastly improves page loading performance in tandem with caching on the server. A similar implementation will eventually make its way to the audio bbcode and will also be worked in the img bbcode somehow. This will then eventually make it possible to embed audio and video in markdown the same way you'd embed an image. --- assets/css/misuzu/embed.css | 167 +++++++++++++++++ assets/js/misuzu/_main.js | 264 +++++++++++++++++++++++++++ assets/js/misuzu/uiharu.js | 58 ++++++ src/Parsers/BBCode/Tags/AudioTag.php | 6 +- src/Parsers/BBCode/Tags/VideoTag.php | 36 +--- 5 files changed, 493 insertions(+), 38 deletions(-) create mode 100644 assets/css/misuzu/embed.css create mode 100644 assets/js/misuzu/uiharu.js diff --git a/assets/css/misuzu/embed.css b/assets/css/misuzu/embed.css new file mode 100644 index 0000000..70e4e63 --- /dev/null +++ b/assets/css/misuzu/embed.css @@ -0,0 +1,167 @@ +.embed { + display: inline-block; + overflow: hidden; +} + +.embed iframe { + width: 100%; + height: 100%; + display: block; +} + +.embedph { + display: inline-block; + overflow: hidden; + cursor: pointer; + color: var(--text-colour); + text-decoration: none; +} +.embedph:hover .embedph-bg img, +.embedph:active .embedph-bg img, +.embedph:focus .embedph-bg img, +.embedph:focus-within .embedph-bg img { + transform: scale(1.1); + filter: blur(10px) brightness(80%); +} +.embedph:hover .embedph-info, +.embedph:active .embedph-info, +.embedph:focus .embedph-info, +.embedph:focus-within .embedph-info { + opacity: 0; +} +.embedph:hover .embedph-play, +.embedph:active .embedph-play, +.embedph:focus .embedph-play, +.embedph:focus-within .embedph-play { + opacity: 1; +} +.embedph-bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; +} +.embedph-bg img { + object-fit: cover; + width: 100%; + height: 100%; + display: inline-block; + will-change: transform, filter; + transition: transform .2s, filter .2s; +} +.embedph-fg { + width: 100%; + height: 100%; +} + +.embedph-info { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: flex-end; + will-change: opacity; + transition: opacity .2s; +} +.embedph-info-wrap { + margin: 5px; + background-color: var(--background-colour-translucent-8); + border-radius: 5px; + display: flex; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} +.embedph-info-bar { + width: 5px; + margin: 5px; + border-radius: 5px; + flex: 0 0 auto; + background-color: var(--embedph-colour, var(--accent-colour)); +} +.embedph-info-body { + margin: 10px; + margin-left: 5px; +} +.embedph-info-title { + font-size: 2em; + font-weight: 400; + line-height: 1.1em; + margin-bottom: 5px; +} +.embedph-info-desc { + line-height: 1.4em; + margin: .5em 0; +} +.embedph-info-site { + font-size: .9em; + line-height: 1.2em; +} + +@media (max-width: 640px) { + .embedph-info-title { + font-size: 1.5em; + line-height: 1.2em; + } + .embedph-info-desc { + display: none; + } +} + +.embedph-play { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + will-change: opacity; + transition: opacity .2s; +} +.embedph-play-internal { + margin-top: 40px; + margin-bottom: 20px; +} +.embedph-play-external { + padding: 5px 10px; + font-size: 1.2em; + line-height: 1.5em; + text-decoration: none; + color: var(--text-colour); + background-color: var(--background-colour-translucent-6); + border-radius: 5px; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + will-change: background-color; + transition: background-color .2s, transform .2s; +} +.embedph-play-external:hover, +.embedph-play-external:focus { + background-color: var(--background-colour-translucent-8); + transform: scale(1.2); +} + +.embed-youtube, +.embedph-youtube { + aspect-ratio: 16 / 9; + width: 100%; + height: 100%; + max-width: 560px; + max-height: 315px; +} + +.embed-nicovideo, +.embedph-nicovideo { + aspect-ratio: 16 / 9; + width: 100%; + height: 100%; + max-width: 640px; + max-height: 360px; +} diff --git a/assets/js/misuzu/_main.js b/assets/js/misuzu/_main.js index 1489c47..a07d166 100644 --- a/assets/js/misuzu/_main.js +++ b/assets/js/misuzu/_main.js @@ -1,4 +1,6 @@ var Misuzu = function() { + const UIHARU_API = location.protocol + '//uiharu.' + location.host; + timeago.render($qa('time')); hljs.initHighlighting(); @@ -6,6 +8,268 @@ var Misuzu = function() { Misuzu.Forum.Editor.init(); Misuzu.Events.dispatch(); Misuzu.initLoginPage(); + + const embeds = Array.from($qa('.js-msz-embed-media')); + if(embeds.length > 0) { + $as(embeds); + + const uiharu = new Uiharu(UIHARU_API) + elems = new Map(embeds.map(function(elem) { return [elem.dataset.mszEmbedUrl, elem]; })); + + uiharu.lookupMany(Array.from(elems.keys()), function(resp) { + if(resp.results === undefined) + return; // rip + + for(const result of resp.results) { + if(result.error) { + console.error(result.error); + continue; + } + + if(result.info.title === undefined) { + console.warn('Media is no longer available.'); + continue; + } + + let elem = elems.get(result.url); + + (function(elem, info) { + const replaceElement = function(body) { + $ib(elem, body); + $r(elem); + elem = body; + }; + + const createEmbedPH = function(type, info, onclick, width, height) { + let infoChildren = []; + + infoChildren.push({ + tag: 'h1', + attrs: { + className: 'embedph-info-title', + }, + child: info.title, + }); + + if(info.description) { + let firstLine = info.description.split("\n")[0].trim(); + if(firstLine.length > 300) + firstLine = firstLine.substring(0, 300).trim() + '...'; + + infoChildren.push({ + tag: 'div', + attrs: { + className: 'embedph-info-desc', + }, + child: firstLine, + }); + } + + infoChildren.push({ + tag: 'div', + attrs: { + className: 'embedph-info-site', + }, + child: info.site_name, + }); + + let style = info.color === undefined ? '' : ('--embedph-colour: ' + info.color); + + if(width !== undefined) + style += 'width: ' + width.toString() + ';'; + if(height !== undefined) + style += 'height: ' + height.toString() + ';'; + + let bgElem; + if(info.image !== undefined) { + bgElem = { + tag: 'img', + attrs: { + src: info.image, + }, + }; + } else { + bgElem = {}; + } + + return $e({ + attrs: { + href: 'javascript:void(0);', + className: ('embedph embedph-' + type), + style: style, + }, + child: [ + { + attrs: { + className: 'embedph-bg', + }, + child: bgElem, + }, + { + attrs: { + className: 'embedph-fg', + }, + child: [ + { + attrs: { + className: 'embedph-info', + }, + child: { + attrs: { + className: 'embedph-info-wrap', + }, + child: [ + { + attrs: { + className: 'embedph-info-bar', + }, + }, + { + attrs: { + className: 'embedph-info-body', + }, + child: infoChildren, + } + ], + }, + }, + { + attrs: { + className: 'embedph-play', + onclick: function(ev) { + if(ev.target.tagName.toLowerCase() !== 'a') + onclick(ev); + }, + }, + child: [ + { + attrs: { + className: 'embedph-play-internal', + }, + child: { + tag: 'i', + attrs: { + className: 'fas fa-play fa-4x fa-fw', + }, + }, + }, + { + tag: 'a', + attrs: { + className: 'embedph-play-external', + href: info.url, + target: '_blank', + rel: 'noopener', + }, + child: ('or watch on ' + info.site_name + '?'), + } + ], + } + ], + }, + ], + }); + }; + + if(info.type === 'youtube:video') { + let embedUrl = 'https://www.youtube.com/embed/' + info.youtube_video_id + '?rel=0&autoplay=1'; + + if(info.youtube_start_time) + embedUrl += '&t=' + encodeURIComponent(info.youtube_start_time); + + if(info.youtube_playlist) { + embedUrl += '&list=' + encodeURIComponent(info.youtube_playlist); + + if(info.youtube_playlist_index) + embedUrl += '&index=' + encodeURIComponent(info.youtube_playlist_index); + } + + replaceElement(createEmbedPH('youtube', info, function() { + replaceElement($e({ + attrs: { + className: 'embed embed-youtube', + }, + child: { + tag: 'iframe', + attrs: { + frameborder: 0, + allow: 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture', + allowfullscreen: 'allowfullscreen', + src: embedUrl, + }, + }, + })); + })); + } else if(info.type === 'niconico:video') { + let embedUrl = 'https://embed.nicovideo.jp/watch/' + info.nicovideo_video_id + '/script?w=100%25&h=100%25&autoplay=1'; + + if(info.nicovideo_start_time) + embedUrl += '&from=' + encodeURIComponent(info.nicovideo_start_time); + + replaceElement(createEmbedPH('nicovideo', info, function() { + replaceElement($e({ + attrs: { + className: 'embed embed-nicovideo', + }, + child: { + tag: 'script', + attrs: { + async: 'async', + src: embedUrl, + }, + }, + })); + })); + } else if(info.type === 'media') { + if(info.is_video) { + let width = info.width, + height = info.height; + + const gcd = function(a, b) { + return (b == 0) ? a : gcd(b, a % b); + }; + + let ratio = gcd(width, height), + widthRatio = width / ratio, + heightRatio = height / ratio; + + if(width > height) { + width = Math.min(640, width); + height = Math.ceil((width / widthRatio) * heightRatio).toString() + 'px'; + width = width.toString() + 'px'; + } else { + height = Math.min(360, height); + width = Math.ceil((height / heightRatio) * widthRatio).toString() + 'px'; + height = height.toString() + 'px'; + } + + replaceElement(createEmbedPH('external', info, function() { + replaceElement($e({ + attrs: { + className: 'embed embed-external', + }, + child: { + tag: 'video', + attrs: { + autoplay: 'autoplay', + controls: 'controls', + src: info.url, + style: { + width: width, + height: height, + }, + }, + }, + })); + }, width, height)); + } else if(info.is_audio) { + // coming someday + } + } + })(elem, result.info); + } + }); + } }; Misuzu.showMessageBox = function(text, title, buttons) { if($q('.messagebox')) diff --git a/assets/js/misuzu/uiharu.js b/assets/js/misuzu/uiharu.js new file mode 100644 index 0000000..27a817a --- /dev/null +++ b/assets/js/misuzu/uiharu.js @@ -0,0 +1,58 @@ +const Uiharu = function(apiUrl) { + const maxBatchSize = 5; + const lookupOneUrl = apiUrl + '/metadata', + lookupManyUrl = apiUrl + '/metadata/batch'; + + const lookupManyInternal = function(targetUrls, callback) { + const formData = new FormData; + + for(const url of targetUrls) + formData.append('url[]', url); + + const xhr = new XMLHttpRequest; + xhr.addEventListener('load', function() { + callback(JSON.parse(xhr.responseText)); + }); + xhr.addEventListener('error', function(ev) { + callback({ status: xhr.status, error: 'xhr', details: ev }); + }); + xhr.open('POST', lookupManyUrl); + xhr.send(formData); + }; + + return { + lookupOne: function(targetUrl, callback) { + if(typeof callback !== 'function') + throw 'callback is missing'; + targetUrl = (targetUrl || '').toString(); + if(targetUrl.length < 1) + return; + + const xhr = new XMLHttpRequest; + xhr.addEventListener('load', function() { + callback(JSON.parse(xhr.responseText)); + }); + xhr.addEventListener('error', function() { + callback({ status: xhr.status, error: 'xhr', details: ex }); + }); + xhr.open('POST', lookupOneUrl); + xhr.send(targetUrl); + }, + lookupMany: function(targetUrls, callback) { + if(!Array.isArray(targetUrls)) + throw 'targetUrls must be an array of urls'; + if(typeof callback !== 'function') + throw 'callback is missing'; + if(targetUrls < 1) + return; + + if(targetUrls.length <= maxBatchSize) { + lookupManyInternal(targetUrls, callback); + return; + } + + for(let i = 0; i < targetUrls.length; i += maxBatchSize) + lookupManyInternal(targetUrls.slice(i, i + maxBatchSize), callback); + }, + }; +}; diff --git a/src/Parsers/BBCode/Tags/AudioTag.php b/src/Parsers/BBCode/Tags/AudioTag.php index eabe26f..c4735a8 100644 --- a/src/Parsers/BBCode/Tags/AudioTag.php +++ b/src/Parsers/BBCode/Tags/AudioTag.php @@ -10,13 +10,11 @@ final class AudioTag extends BBCodeTag { function ($matches) { $url = parse_url($matches[1]); - if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true)) { + if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true)) return $matches[0]; - } - //$url['host'] = mb_strtolower($url['host']); + //return sprintf('%1$s', $matches[1]); - //$mediaUrl = url_proxy_media($matches[1]); $mediaUrl = $matches[1]; return ""; }, diff --git a/src/Parsers/BBCode/Tags/VideoTag.php b/src/Parsers/BBCode/Tags/VideoTag.php index c7850e6..1bc2d27 100644 --- a/src/Parsers/BBCode/Tags/VideoTag.php +++ b/src/Parsers/BBCode/Tags/VideoTag.php @@ -4,48 +4,16 @@ namespace Misuzu\Parsers\BBCode\Tags; use Misuzu\Parsers\BBCode\BBCodeTag; final class VideoTag extends BBCodeTag { - private const YOUTUBE_REGEX = '#^(?:www\.)?youtube(?:-nocookie)?\.(?:[a-z]{2,63})$#u'; - private const YOUTUBE_EMBED = ''; - - private const NICODOUGA_EMBED = ''; - public function parseText(string $text): string { return preg_replace_callback( '#\[video\]((?:https?:\/\/).+?)\[/video\]#', function ($matches) { $url = parse_url($matches[1]); - if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true)) { + if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true)) return $matches[0]; - } - $url['host'] = mb_strtolower($url['host']); - - // support youtube playlists? - - if($url['host'] === 'youtu.be' || $url['host'] === 'www.youtu.be') { - return sprintf(self::YOUTUBE_EMBED, $url['path']); - } - - if(!empty($url['query']) && ($url['path'] ?? '') === '/watch' && preg_match(self::YOUTUBE_REGEX, $url['host'])) { - parse_str(html_entity_decode($url['query']), $ytQuery); - - if(!empty($ytQuery['v']) && preg_match('#^([a-zA-Z0-9_-]+)$#u', $ytQuery['v'])) { - return sprintf(self::YOUTUBE_EMBED, $ytQuery['v']); - } - } - - if($url['host'] === 'nicovideo.jp' || $url['host'] === 'www.nicovideo.jp') { - $splitPath = explode('/', trim($url['path'], '/')); - - if(count($splitPath) > 1 && $splitPath[0] === 'watch') { - return sprintf(self::NICODOUGA_EMBED, $splitPath[1]); - } - } - - //$mediaUrl = url_proxy_media($matches[1]); - $mediaUrl = $matches[1]; - return sprintf('', $mediaUrl); + return sprintf('%1$s', $matches[1]); }, $text );