From e1122fb6b9676c6242be485222771588ce59e6d4 Mon Sep 17 00:00:00 2001 From: flashwave Date: Sun, 29 Jan 2023 01:51:54 +0000 Subject: [PATCH] Improved video embed handling. --- assets/css/misuzu/embed.css | 41 ++ assets/js/misuzu/_main.js | 338 +------------ assets/js/misuzu/embed.js | 117 +++++ assets/js/misuzu/forum/editor.js | 4 +- assets/js/misuzu/rng.js | 38 ++ assets/js/misuzu/vembed.js | 843 +++++++++++++++++++++++++++++++ assets/js/misuzu/watcher.js | 83 +++ 7 files changed, 1128 insertions(+), 336 deletions(-) create mode 100644 assets/js/misuzu/embed.js create mode 100644 assets/js/misuzu/rng.js create mode 100644 assets/js/misuzu/vembed.js create mode 100644 assets/js/misuzu/watcher.js diff --git a/assets/css/misuzu/embed.css b/assets/css/misuzu/embed.css index acff610..01f8351 100644 --- a/assets/css/misuzu/embed.css +++ b/assets/css/misuzu/embed.css @@ -1,6 +1,7 @@ .embed { display: inline-block; overflow: hidden; + text-shadow: initial; } .embed iframe { @@ -15,6 +16,7 @@ cursor: pointer; color: var(--text-colour); text-decoration: none; + text-shadow: initial; } .embedph:hover .embedph-bg img, .embedph:active .embedph-bg img, @@ -163,3 +165,42 @@ max-width: 640px; max-height: 360px; } + +.embedvf { + display: inline-block; + overflow: hidden; +} +.embedvf:hover .embedvf-controls, +.embedvf:focus .embedvf-controls, +.embedvf:active .embedvf-controls, +.embedvf:focus-within .embedvf-controls { + opacity: 1; + transform: scale(1); +} +.embedvf-player { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} +.embedvf-overlay { + width: 100%; + height: 100%; + pointer-events: none; +} +.embedvf-controls { + pointer-events: initial; + position: absolute; + bottom: 5px; + left: 5px; + right: 5px; + padding: 5px; + background-color: var(--background-colour-translucent-7); + border-radius: 5px; + opacity: 0; + transform: scale(.95); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: opacity .2s, transform .2s; +} diff --git a/assets/js/misuzu/_main.js b/assets/js/misuzu/_main.js index 8856081..1d9a161 100644 --- a/assets/js/misuzu/_main.js +++ b/assets/js/misuzu/_main.js @@ -2,11 +2,14 @@ var Misuzu = function() { timeago.render($qa('time')); hljs.initHighlighting(); + MszEmbed.init(location.protocol + '//uiharu.' + location.host); + Misuzu.initQuickSubmit(); // only used by the forum posting form Misuzu.Forum.Editor.init(); Misuzu.Events.dispatch(); Misuzu.initLoginPage(); - Misuzu.handleEmbeds(); + + MszEmbed.handle($qa('.js-msz-embed-media')); }; Misuzu.showMessageBox = function(text, title, buttons) { if($q('.messagebox')) @@ -122,336 +125,3 @@ Misuzu.initQuickSubmit = function() { } }); }; -Misuzu.handleEmbeds = function() { - const UIHARU_API = location.protocol + '//uiharu.' + location.host; - - const embeds = Array.from($qa('.js-msz-embed-media')); - if(embeds.length > 0) { - $as(embeds); - - const uiharu = new Uiharu(UIHARU_API), - elems = new Map; - - for(const elem of embeds) { - let cleanUrl = elem.dataset.mszEmbedUrl.replace(/ /, '%20'); - if(cleanUrl.indexOf('http://') !== 0 && cleanUrl.indexOf('https://') !== 0) { - elem.textContent = elem.dataset.mszEmbedUrl; - continue; - } - - elem.textContent = 'Loading...'; - - if(elems.has(cleanUrl)) - elems.get(cleanUrl).push(elem); - else - elems.set(cleanUrl, [elem]); - } - - uiharu.lookupMany(Array.from(elems.keys()), function(resp) { - if(resp.results === undefined) - return; // rip - - for(const result of resp.results) { - let elemList = elems.get(result.url); - - const replaceWithUrl = function() { - for(let i = 0; i < elemList.length; ++i) { - let body = $e({ - tag: 'a', - attrs: { - className: 'link', - href: result.url, - target: '_blank', - rel: 'noopener noreferrer', - }, - child: result.url - }); - $ib(elemList[i], body); - $r(elemList[i]); - elemList[i] = body; - } - }; - - if(result.error) { - replaceWithUrl(); - console.error(result.error); - continue; - } - - if(result.info.title === undefined) { - replaceWithUrl(); - console.warn('Media is no longer available.'); - continue; - } - - - (function(elemList, info) { - const replaceElement = function(bodyInfo) { - for(let i = 0; i < elemList.length; ++i) { - let body = $e(bodyInfo); - $ib(elemList[i], body); - $r(elemList[i]); - elemList[i] = 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 { - 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({ - 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({ - attrs: { - className: 'embed embed-nicovideo', - }, - child: { - tag: 'script', - attrs: { - async: 'async', - src: embedUrl, - }, - }, - }); - })); - } else if(info.type === 'media') { - // todo: proxying - // think uiharu will just serve as the camo system - 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({ - 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) { - // need dedicated audio placeholder and a player frame that includes the cover art - replaceElement(createEmbedPH('external', info, function() { - replaceElement({ - attrs: { - className: 'embed embed-external', - }, - child: { - tag: 'audio', - attrs: { - autoplay: 'autoplay', - controls: 'controls', - src: info.url, - }, - }, - }); - }, '300px', '300px')); - } else if(info.is_image) { - replaceElement({ - tag: 'img', - attrs: { - src: info.url, - alt: info.url, - style: { - maxWidth: '100%', - maxHeight: '900px', - }, - }, - }); - } - } - })(elemList, result.info); - } - }); - } -}; diff --git a/assets/js/misuzu/embed.js b/assets/js/misuzu/embed.js new file mode 100644 index 0000000..88fe365 --- /dev/null +++ b/assets/js/misuzu/embed.js @@ -0,0 +1,117 @@ +var MszEmbed = (function() { + let uiharu = undefined; + + return { + init: function(endPoint) { + uiharu = new Uiharu(endPoint); + }, + handle: function(targets) { + if(!Array.isArray(targets)) + targets = Array.from(targets); + + const filtered = new Map; + for(const target of targets) { + if(!(target instanceof HTMLElement) + || !('dataset' in target) + || !('mszEmbedUrl' in target.dataset)) + continue; + + const cleanUrl = target.dataset.mszEmbedUrl.replace(/ /, '%20'); + if(cleanUrl.indexOf('https://') !== 0 + && cleanUrl.indexOf('http://') !== 0) { + target.textContent = target.dataset.mszEmbedUrl; + continue; + } + + $rc(target); + target.appendChild($e({ + tag: 'i', + attrs: { + className: 'fas fa-2x fa-spinner fa-pulse', + style: { + width: '32px', + height: '32px', + lineHeight: '32px', + textAlign: 'center', + }, + }, + })); + + if(filtered.has(cleanUrl)) + filtered.get(cleanUrl).push(target); + else + filtered.set(cleanUrl, [target]); + } + + const replaceWithUrl = function(targets, url) { + for(const target of targets) { + let body = $e({ + tag: 'a', + attrs: { + className: 'link', + href: url, + target: '_blank', + rel: 'noopener noreferrer', + }, + child: url + }); + $ib(target, body); + $r(target); + } + }; + + filtered.forEach(function(targets, url) { + uiharu.lookupOne(url, function(metadata) { + if(metadata.error) { + replaceWithUrl(targets, url); + console.error(metadata.error); + return; + } + + if(metadata.title === undefined) { + replaceWithUrl(targets, url); + console.warn('Media is no longer available.'); + return; + } + + let phc = undefined, + options = { + onembed: console.log, + }; + + if(metadata.type === 'youtube:video') { + phc = MszVideoEmbedPlaceholder; + options.type = 'youtube'; + options.player = MszVideoEmbedYouTube; + } else if(metadata.type === 'niconico:video') { + phc = MszVideoEmbedPlaceholder; + options.type = 'nicovideo'; + options.player = MszVideoEmbedNicoNico; + } else if(metadata.is_video) { + phc = MszVideoEmbedPlaceholder; + options.type = 'external'; + options.player = MszVideoEmbedPlayer; + //options.frame = MszVideoEmbedFrame; + options.nativeControls = true; + options.autosize = false; + options.maxWidth = 640; + options.maxHeight = 360; + } else if(metadata.is_audio) { + options.type = 'external'; + } else if(metadata.is_image) { + options.type = 'external'; + } + + if(phc === undefined) + return; + + for(const target of targets) { + const placeholder = new phc(metadata, options); + if(placeholder !== undefined) + placeholder.replaceElement(target); + } + }); + }); + }, + }; +})(); diff --git a/assets/js/misuzu/forum/editor.js b/assets/js/misuzu/forum/editor.js index fb42abf..f23dccd 100644 --- a/assets/js/misuzu/forum/editor.js +++ b/assets/js/misuzu/forum/editor.js @@ -64,7 +64,7 @@ Misuzu.Forum.Editor.init = function() { lastPostParser = postParser; postingPreview.innerHTML = text; - Misuzu.handleEmbeds(); + MszEmbed.handle($qa('.js-msz-embed-media')); previewButton.removeAttribute('disabled'); postingParser.removeAttribute('disabled'); @@ -113,7 +113,7 @@ Misuzu.Forum.Editor.init = function() { lastPostText = postText; lastPostParser = postParser; postingPreview.innerHTML = text; - Misuzu.handleEmbeds(); + MszEmbed.handle($qa('.js-msz-embed-media')); postingPreview.removeAttribute('hidden'); postingText.setAttribute('hidden', 'hidden'); diff --git a/assets/js/misuzu/rng.js b/assets/js/misuzu/rng.js new file mode 100644 index 0000000..966ca6b --- /dev/null +++ b/assets/js/misuzu/rng.js @@ -0,0 +1,38 @@ +const MszRandomInt = function(min, max) { + let ret = 0; + const range = max - min; + + const bitsNeeded = Math.ceil(Math.log2(range)); + if(bitsNeeded > 53) + return -1; + + const bytesNeeded = Math.ceil(bitsNeeded / 8), + mask = Math.pow(2, bitsNeeded) - 1; + + const bytes = new Uint8Array(bytesNeeded); + crypto.getRandomValues(bytes); + + let p = (bytesNeeded - 1) * 8; + for(let i = 0; i < bytesNeeded; ++i) { + ret += bytes[i] * Math.pow(2, p); + p -= 8; + } + + ret &= mask; + + if(ret >= range) + return MszRandomInt(min, max); + + return min + ret; +}; + +const MszUniqueStr = (function() { + const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789'; + + return function(length) { + let str = ''; + for(let i = 0; i < length; ++i) + str += chars[MszRandomInt(0, chars.length)]; + return str; + }; +})(); diff --git a/assets/js/misuzu/vembed.js b/assets/js/misuzu/vembed.js new file mode 100644 index 0000000..f96723e --- /dev/null +++ b/assets/js/misuzu/vembed.js @@ -0,0 +1,843 @@ +const MszVideoEmbedPlayerEvents = function() { + return [ + 'play', 'pause', 'stop', + 'mute', 'volume', + 'rate', 'duration', 'time', + ]; +}; + +const MszVideoEstimateAspectRatio = function(width, height) { + const gcd = function(a, b) { return b ? gcd(b, a % b) : Math.abs(a); }; + + const ratio = gcd(width, height); + width /= ratio; + height /= ratio; + + return [width, height]; +}; + +const MszVideoConstrainSize = function(w, h, mw, mh) { + mw = mw || 0; + mh = mh || 0; + w = w || 0; + h = h || 0; + + const ar = MszVideoEstimateAspectRatio(w, h); + + if(w > h) { + if(mw > 0) { + w = Math.min(mw, w); + h = Math.ceil((w / ar[0]) * ar[1]); + if(mh > 0) h = Math.min(mh, h); + } + } else { + if(mh > 0) { + h = Math.min(mh, h); + w = Math.ceil((h / ar[1]) * ar[0]); + if(mw > 0) w = Math.min(mw, w); + } + } + + return [w, h]; +}; + +const MszVideoEmbed = function(playerOrFrame) { + const frame = playerOrFrame; + const player = 'getPlayer' in frame ? frame.getPlayer() : frame; + + const elem = $e({ + attrs: { + classList: ['embed', 'embed-' + player.getType()], + }, + child: frame, + }); + + return { + getElement: function() { + return elem; + }, + appendTo: function(target) { + target.appendChild(elem); + }, + insertBefore: function(ref) { + $ib(ref, elem); + }, + nuke: function() { + $r(elem); + }, + replaceElement(target) { + $ib(target, elem); + $r(target); + }, + getFrame: function() { + return frame; + }, + getPlayer: function() { + return player; + }, + }; +}; + +const MszVideoEmbedFrame = function(player, options) { + options = options || {}; + + const elem = $e({ + attrs: { + className: 'embedvf', + style: { + width: player.getWidth().toString() + 'px', + height: player.getHeight().toString() + 'px', + }, + }, + child: [ + { + attrs: { + className: 'embedvf-player', + }, + child: player, + }, + { + attrs: { + className: 'embedvf-overlay', + }, + child: [ + { + attrs: { + className: 'embedvf-controls', + }, + child: [ + 'Play/Pause Stop [|---------] 1.00x 100%', + ], + }, + ], + }, + ], + }); + + return { + getElement: function() { + return elem; + }, + appendTo: function(target) { + target.appendChild(elem); + }, + insertBefore: function(ref) { + $ib(ref, elem); + }, + nuke: function() { + $r(elem); + }, + replaceElement(target) { + $ib(target, elem); + $r(target); + }, + getPlayer: function() { + return player; + }, + }; +}; + +const MszVideoEmbedPlayer = function(metadata, options) { + options = options || {}; + + const shouldAutoplay = options.autoplay === undefined || options.autoplay, + haveNativeControls = options.nativeControls !== undefined && options.nativeControls, + shouldObserveResize = options.observeResize === undefined || options.observeResize; + + const videoAttrs = { + src: metadata.url, + style: {}, + }; + + if(shouldAutoplay) + videoAttrs.autoplay = 'autoplay'; + if(haveNativeControls) + videoAttrs.controls = 'controls'; + + const constrainSize = function(w, h, mw, mh) { + return MszVideoConstrainSize(w, h, mw || options.maxWidth, mh || options.maxHeight); + }; + + const initialSize = constrainSize( + options.width || metadata.width || 200, + options.height || metadata.height || 200 + ); + + videoAttrs.style.width = initialSize[0].toString() + 'px'; + videoAttrs.style.height = initialSize[1].toString() + 'px'; + + const watchers = new MszWatcherCollection; + watchers.define(MszVideoEmbedPlayerEvents()); + + const player = $e({ + tag: 'video', + attrs: videoAttrs, + }); + + const setSize = function(w, h) { + const size = constrainSize(w, h, initialSize[0], initialSize[1]); + player.style.width = size[0].toString() + 'px'; + player.style.height = size[1].toString() + 'px'; + }; + + const pub = { + getElement: function() { + return player; + }, + appendTo: function(target) { + target.appendChild(player); + }, + insertBefore: function(ref) { + $ib(ref, player); + }, + nuke: function() { + $r(player); + }, + replaceElement(target) { + $ib(target, player); + $r(target); + }, + getType: function() { return 'external'; }, + getWidth: function() { return width; }, + getHeight: function() { return height; }, + }; + + watchers.proxy(pub); + + if(shouldObserveResize) + player.addEventListener('resize', function() { setSize(player.videoWidth, player.videoHeight); }); + + player.addEventListener('play', function() { watchers.call('play', pub); }); + + const pPlay = function() { player.play(); }; + pub.play = pPlay; + + const pPause = function() { player.pause(); }; + pub.pause = pPause; + + let stopCalled = false; + player.addEventListener('pause', function() { + watchers.call(stopCalled ? 'stop' : 'pause', pub); + stopCalled = false; + }); + + const pStop = function() { + stopCalled = true; + player.pause(); + player.currentTime = 0; + }; + pub.stop = pStop; + + const pIsPlaying = function() { return !player.paused; }; + pub.isPlaying = pIsPlaying; + + const pIsMuted = function() { return player.muted; }; + pub.isMuted = pIsMuted; + + let lastMuteState = player.muted; + player.addEventListener('volumechange', function() { + if(lastMuteState !== player.muted) { + lastMuteState = player.muted; + watchers.call('mute', pub, [lastMuteState]); + } else + watchers.call('volume', pub, [player.volume]); + }); + + const pSetMuted = function(state) { player.muted = state; }; + pub.setMuted = pSetMuted; + + const pGetVolume = function() { return player.volume; }; + pub.getVolume = pGetVolume; + + const pSetVolume = function(volume) { player.volume = volume; }; + pub.setVolume = pSetVolume; + + const pGetPlaybackRate = function() { return player.playbackRate; }; + pub.getPlaybackRate = pGetPlaybackRate; + + player.addEventListener('ratechange', function() { + watchers.call('rate', pub, [player.playbackRate]); + }); + + const pSetPlaybackRate = function(rate) { player.playbackRate = rate; }; + pub.setPlaybackRate = pSetPlaybackRate; + + window.addEventListener('durationchange', function() { + watchers.call('duration', pub, [player.duration]); + }); + + const pGetDuration = function() { return player.duration; }; + pub.getDuration = pGetDuration; + + window.addEventListener('timeupdate', function() { + watchers.call('time', pub, [player.currentTime]); + }); + + const pGetTime = function() { return player.currentTime; }; + pub.getTime = pGetTime; + + const pSeek = function(time) { player.currentTime = time; }; + pub.seek = pSeek; + + return pub; +}; + +const MszVideoEmbedYouTube = function(metadata, options) { + options = options || {}; + + const ytOrigin = 'https://www.youtube.com', + playerId = 'yt-' + MszUniqueStr(8), + shouldAutoplay = options.autoplay === undefined || options.autoplay; + + let embedUrl = 'https://www.youtube.com/embed/' + metadata.youtube_video_id + '?enablejsapi=1'; + + embedUrl += '&origin=' + encodeURIComponent(location.origin); + + if(metadata.youtube_start_time) + embedUrl += '&t=' + encodeURIComponent(metadata.youtube_start_time); + + if(metadata.youtube_playlist) { + embedUrl += '&list=' + encodeURIComponent(metadata.youtube_playlist); + + if(metadata.youtube_playlist_index) + embedUrl += '&index=' + encodeURIComponent(metadata.youtube_playlist_index); + } + + let presetPlaybackRates = undefined, + isMuted = undefined, + volume = undefined, + playbackRate = undefined, + duration = undefined, + currentTime = undefined, + isPlaying = undefined; + + const watchers = new MszWatcherCollection; + watchers.define(MszVideoEmbedPlayerEvents()); + + const player = $e({ + tag: 'iframe', + attrs: { + frameborder: 0, + allow: 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture', + allowfullscreen: 'allowfullscreen', + src: embedUrl, + }, + }); + + const pub = { + getElement: function() { + return player; + }, + appendTo: function(target) { + target.appendChild(player); + }, + insertBefore: function(ref) { + $ib(ref, player); + }, + nuke: function() { + $r(player); + }, + replaceElement(target) { + $ib(target, player); + $r(target); + }, + getType: function() { return 'youtube'; }, + getWidth: function() { return 560; }, + getHeight: function() { return 315; }, + getPlayerId: function() { return playerId; }, + }; + + watchers.proxy(pub); + + const postMessage = function(data) { + player.contentWindow.postMessage(JSON.stringify(data), ytOrigin); + }; + const postCommand = function(name, args) { + postMessage({ + id: playerId, + event: 'command', + func: name, + args: args || [], + }); + }; + + const cmdPlay = function() { postCommand('playVideo'); }; + const cmdPause = function() { postCommand('pauseVideo'); }; + const cmdStop = function() { postCommand('stopVideo'); }; + const cmdMute = function() { postCommand('mute'); }; + const cmdUnMute = function() { postCommand('unMute'); }; + const cmdSetVolume = function(volume) { postCommand('setVolume', [volume * 100]); }; + const cmdSetPlaybackRate = function(rate) { postCommand('setPlaybackRate', [rate]); }; + const cmdSeekTo = function(timecode) { postCommand('seekTo', [timecode, true]); }; + + const cmdMuteState = function(state) { + if(state) cmdMute(); + else cmdUnMute(); + }; + + const varIsPlaying = function() { return isPlaying; }; + const varIsMuted = function() { return isMuted; }; + const varVolume = function() { return volume; }; + const varRate = function() { return playbackRate; }; + const varDuration = function() { return duration; }; + const varTime = function() { return currentTime; }; + + let lastPlayerState = undefined, + lastPlayerStateEvent = undefined; + const handlePlayerState = function(state) { + let eventName = undefined; + if(state === 1) { + isPlaying = true; + eventName = 'play'; + } else { + isPlaying = false; + if(state === 2) + eventName = 'pause'; + else if(lastPlayerState === -1 && state === 5) + eventName = 'stop'; + } + + lastPlayerState = state; + if(eventName !== undefined && eventName !== lastPlayerStateEvent) { + lastPlayerStateEvent = eventName; + watchers.call(eventName, pub); + } + }; + + const handleMuted = function(muted) { + isMuted = muted; + watchers.call('mute', pub, [isMuted]); + }; + + const handleVolume = function(value) { + volume = value / 100; + watchers.call('volume', pub, [volume]); + }; + + const handleRate = function(rate) { + playbackRate = rate; + watchers.call('rate', pub, [playbackRate]); + }; + + const handleDuration = function(time) { + duration = time; + watchers.call('duration', pub, [duration]); + }; + + const handleTime = function(time) { + currentTime = time; + watchers.call('time', pub, [currentTime]); + }; + + const handlePresetRates = function(rates) { + presetPlaybackRates = rates; + }; + + const infoHandlers = { + 'playerState': handlePlayerState, + 'muted': handleMuted, + 'volume': handleVolume, + 'playbackRate': handleRate, + 'duration': handleDuration, + 'currentTime': handleTime, + 'availablePlaybackRates': handlePresetRates, + }; + + const processInfo = function(info) { + for(const name in infoHandlers) + if(name in info && info[name] !== undefined) + infoHandlers[name](info[name]); + }; + + window.addEventListener('message', function(ev) { + if(ev.origin !== ytOrigin || typeof ev.data !== 'string') + return; + + const data = JSON.parse(ev.data); + if(!data || data.id !== playerId) + return; + + if(data.event === 'initialDelivery') { + if(data.info !== undefined) + processInfo(data.info); + } else if(data.event === 'onReady') { + if(shouldAutoplay) + cmdPlay(); + } else if(data.event === 'infoDelivery') { + if(data.info !== undefined) + processInfo(data.info); + } + }); + + player.addEventListener('load', function(ev) { + postMessage({ + id: playerId, + event: 'listening', + }); + }); + + pub.play = cmdPlay; + pub.pause = cmdPause; + pub.stop = cmdStop; + pub.isPlaying = varIsPlaying; + pub.isMuted = varIsMuted; + pub.setMuted = cmdMuteState; + pub.getVolume = varVolume; + pub.setVolume = cmdSetVolume; + pub.getRate = varRate; + pub.setRate = cmdSetPlaybackRate; + pub.getDuration = varDuration; + pub.getTime = varTime; + pub.seek = cmdSeekTo; + + return pub; +}; + +const MszVideoEmbedNicoNico = function(metadata, options) { + options = options || {}; + + const nndOrigin = 'https://embed.nicovideo.jp', + playerId = 'nnd-' + MszUniqueStr(8), + shouldAutoplay = options.autoplay === undefined || options.autoplay; + + let embedUrl = 'https://embed.nicovideo.jp/watch/' + metadata.nicovideo_video_id + '?jsapi=1&playerId=' + playerId; + + if(metadata.nicovideo_start_time) + embedUrl += '&from=' + encodeURIComponent(metadata.nicovideo_start_time); + + let isMuted = undefined, + volume = undefined, + duration = undefined, + currentTime = undefined, + isPlaying = false; + + const watchers = new MszWatcherCollection; + watchers.define(MszVideoEmbedPlayerEvents()); + + const player = $e({ + tag: 'iframe', + attrs: { + frameborder: 0, + allow: 'autoplay', + allowfullscreen: 'allowfullscreen', + src: embedUrl, + }, + }); + + const pub = { + getElement: function() { + return player; + }, + appendTo: function(target) { + target.appendChild(player); + }, + insertBefore: function(ref) { + $ib(ref, player); + }, + nuke: function() { + $r(player); + }, + replaceElement(target) { + $ib(target, player); + $r(target); + }, + getType: function() { return 'nicovideo'; }, + getWidth: function() { return 640; }, + getHeight: function() { return 360; }, + getPlayerId: function() { return playerId; }, + }; + + watchers.proxy(pub); + + const postMessage = function(name, data) { + if(name === undefined) + throw 'name must be specified'; + + player.contentWindow.postMessage({ + playerId: playerId, + sourceConnectorType: 1, + eventName: name, + data: data, + }, nndOrigin); + }; + + const cmdPlay = function() { postMessage('play'); }; + const cmdPause = function() { postMessage('pause'); }; + const cmdMute = function(state) { postMessage('mute', { mute: state }); }; + const cmdSeek = function(time) { postMessage('seek', { time: time }); }; + const cmdVolumeChange = function(volume) { postMessage('volumeChange', { volume: volume }); }; + + let stopCalled = false; + const cmdStop = function() { + stopCalled = true; + cmdPause(); + cmdSeek(0); + }; + + const varIsPlaying = function() { return isPlaying; }; + const varIsMuted = function() { return isMuted; }; + const varVolume = function() { return volume; }; + const varDuration = function() { return duration; }; + const varTime = function() { return currentTime; }; + + let lastPlayerStateEvent = undefined; + const handlePlayerStatus = function(status) { + let eventName = undefined; + if(status === 2) { + isPlaying = true; + eventName = 'play'; + } else { + isPlaying = false; + if(status === 4 || stopCalled) { + eventName = 'stop'; + stopCalled = false; + } else if(status === 3) + eventName = 'pause'; + } + + if(eventName !== undefined && eventName !== lastPlayerStateEvent) { + lastPlayerStateEvent = eventName; + watchers.call(eventName, pub); + } + }; + + const handleMuted = function(muted) { + isMuted = muted; + watchers.call('mute', pub, [isMuted]); + }; + + const handleVolume = function(value) { + volume = value; + watchers.call('volume', pub, [volume]); + }; + + const handleDuration = function(time) { + duration = time / 1000; + watchers.call('duration', pub, [duration]); + }; + + const handleTime = function(time) { + currentTime = time / 1000; + watchers.call('time', pub, [currentTime]); + }; + + const metadataHanders = { + 'muted': handleMuted, + 'volume': handleVolume, + 'duration': handleDuration, + 'currentTime': handleTime, + }; + const statusHandlers = { + 'playerStatus': handlePlayerStatus, + }; + + const processData = function(handlers, info) { + for(const name in handlers) + if(name in info && info[name] !== undefined) + handlers[name](info[name]); + }; + + window.addEventListener('message', function(ev) { + if(ev.origin !== nndOrigin || ev.data.playerId !== playerId) + return; + + if(ev.data.eventName === 'loadComplete') { + if(shouldAutoplay) + cmdPlay(); + } else if(ev.data.eventName === 'playerMetadataChange') { + if(ev.data.data !== undefined) + processData(metadataHanders, ev.data.data); + } else if(ev.data.eventName === 'statusChange') { + if(ev.data.data !== undefined) + processData(statusHandlers, ev.data.data); + } + }); + + pub.play = cmdPlay; + pub.pause = cmdPause; + pub.stop = cmdStop; + pub.isPlaying = varIsPlaying; + pub.isMuted = varIsMuted; + pub.setMuted = cmdMute; + pub.getVolume = varVolume; + pub.setVolume = cmdVolumeChange; + pub.getDuration = varDuration; + pub.getTime = varTime; + pub.seek = cmdSeek; + + return pub; +}; + +const MszVideoEmbedPlaceholder = function(metadata, options) { + options = options || {}; + + if(typeof options.player !== 'function' && typeof options.onclick !== 'function') + throw 'Neither a player nor an onclick handler were provided.'; + + const shouldAutoSize = options.autosize === undefined || options.autosize; + + const infoChildren = []; + + infoChildren.push({ + tag: 'h1', + attrs: { + className: 'embedph-info-title', + }, + child: metadata.title, + }); + + if(metadata.description) { + const firstLine = metadata.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: metadata.site_name, + }); + + const style = []; + if(typeof metadata.color !== 'undefined') + style.push('--embedph-colour: ' + metadata.color); + + if(!shouldAutoSize) { + const size = MszVideoConstrainSize( + options.width || metadata.width || 200, + options.height || metadata.height || 200, + options.maxWidth, + options.maxHeight + ); + + style.push('width: ' + size[0].toString() + 'px'); + style.push('height: ' + size[1].toString() + 'px'); + } + + const pub = {}; + + const elem = $e({ + attrs: { + href: 'javascript:void(0);', + className: ('embedph embedph-' + (options.type || 'external')), + style: style.join(';'), + }, + child: [ + { + attrs: { + className: 'embedph-bg', + }, + child: { + tag: 'img', + attrs: { + src: metadata.image, + }, + }, + }, + { + 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') + return; + + if(typeof options.onclick === 'function') { + options.onclick(ev); + return; + } + + const player = new options.player(metadata, options); + let frameOrPlayer = player; + + if(typeof options.frame === 'function') + frameOrPlayer = new options.frame(player, options); + + const embed = new MszVideoEmbed(frameOrPlayer); + if(options.autoembed === undefined || options.autoembed) + embed.replaceElement(elem); + + if(typeof options.onembed === 'function') + options.onembed(embed); + }, + }, + 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: metadata.url, + target: '_blank', + rel: 'noopener', + }, + child: ('or watch on ' + metadata.site_name + '?'), + } + ], + } + ], + }, + ], + }); + + pub.getElement = function() { return elem; }; + pub.appendTo = function(target) { target.appendChild(elem); }; + pub.insertBefore = function(ref) { $ib(ref, elem); }; + pub.nuke = function() { + $r(elem); + elem = null; + }; + pub.replaceElement = function(target) { + $ib(target, elem); + $r(target); + }; + + return pub; +}; diff --git a/assets/js/misuzu/watcher.js b/assets/js/misuzu/watcher.js new file mode 100644 index 0000000..be747e4 --- /dev/null +++ b/assets/js/misuzu/watcher.js @@ -0,0 +1,83 @@ +const MszWatcher = function() { + let watchers = []; + + return { + watch: function(watcher, thisArg, args) { + if(typeof watcher !== 'function') + throw 'watcher must be a function'; + if(watchers.indexOf(watcher) >= 0) + return; + + watchers.push(watcher); + + if(thisArg !== undefined) { + if(!Array.isArray(args)) { + if(args !== undefined) + args = [args]; + else args = []; + } + + // initial call + args.push(true); + + watcher.apply(thisArg, args); + } + }, + unwatch: function(watcher) { + $ari(watchers, watcher); + }, + call: function(thisArg, args) { + if(!Array.isArray(args)) { + if(args !== undefined) + args = [args]; + else args = []; + } + + args.push(false); + + for(const watcher of watchers) + watcher.apply(thisArg, args); + }, + }; +}; + +const MszWatcherCollection = function() { + const collection = new Map; + + const watch = function(name, watcher, thisArg, args) { + const watchers = collection.get(name); + if(watchers === undefined) + throw 'undefined watcher name'; + watchers.watch(watcher, thisArg, args); + }; + + const unwatch = function(name, watcher) { + const watchers = collection.get(name); + if(watchers === undefined) + throw 'undefined watcher name'; + watchers.unwatch(watcher); + }; + + return { + define: function(names) { + if(!Array.isArray(names)) + names = [names]; + for(const name of names) + collection.set(name, new MszWatcher); + }, + call: function(name, thisArg, args) { + const watchers = collection.get(name); + if(watchers === undefined) + throw 'undefined watcher name'; + watchers.call(thisArg, args); + }, + watch: watch, + unwatch: unwatch, + proxy: function(obj) { + obj.watch = function(name, watcher) { + watch(name, watcher); + }; + obj.unwatch = unwatch; + }, + }; +};