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.
This commit is contained in:
flash 2023-01-25 22:33:59 +00:00
parent 6727abe6c3
commit d5bb0bb475
5 changed files with 493 additions and 38 deletions

167
assets/css/misuzu/embed.css Normal file
View file

@ -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;
}

View file

@ -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'))

View file

@ -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);
},
};
};

View file

@ -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('<span class="js-msz-embed-media" data-msz-embed-url="%1$s"><a href="%1$s" class="link" rel="noopener noreferrer">%1$s</a></span>', $matches[1]);
//$mediaUrl = url_proxy_media($matches[1]);
$mediaUrl = $matches[1];
return "<audio controls src='{$mediaUrl}'></audio>";
},

View file

@ -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 = '<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/%s?rel=0" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
private const NICODOUGA_EMBED = '<script async type="application/javascript" src="https://embed.nicovideo.jp/watch/%1$s/script?w=560&h=315" width="560" height="315"></script><noscript><a href="https://www.nicovideo.jp/watch/%1$s">Embedded Video</a></noscript>';
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('<video controls src="%s" style="max-width:100%%;max-height:100%%;"></video>', $mediaUrl);
return sprintf('<span class="js-msz-embed-media" data-msz-embed-url="%1$s"><a href="%1$s" class="link" rel="noopener noreferrer">%1$s</a></span>', $matches[1]);
},
$text
);