// ==UserScript==
// @name Crunchyroll HTML5
// @namespace DoomTay
// @description Replaces Crunchyroll's HTML5 player with an improved version
// @include http://www.crunchyroll.com/*
// @include https://www.crunchyroll.com/*
// @require https://cdnjs.cloudflare.com/ajax/libs/video.js/7.1.0/video.js
// @require https://cdn.jsdelivr.net/npm/@videojs/http-streaming@1.10.5/dist/videojs-http-streaming.min.js
// @require https://cdn.jsdelivr.net/npm/videojs-ima@1.5.1/dist/videojs.ima.js
// @require https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-ads/6.3.0/videojs-contrib-ads.js
// @require https://cdn.jsdelivr.net/gh/Arnavion/libjass@b13173112df83073e03fdd209b87de61f7eb7726/demo/libjass.js
// @require https://cdn.jsdelivr.net/npm/videojs-contrib-quality-levels@2.0.9/dist/videojs-contrib-quality-levels.min.js
// @require https://cdn.jsdelivr.net/npm/videojs-http-source-selector@1.1.6/dist/videojs-http-source-selector.min.js
// @resource vjsCSS https://cdnjs.cloudflare.com/ajax/libs/video.js/7.1.0/video-js.min.css
// @resource libjassCSS https://cdn.jsdelivr.net/gh/Arnavion/libjass@b13173112df83073e03fdd209b87de61f7eb7726/demo/libjass.css
// @resource vjsASSCSS https://cdn.jsdelivr.net/npm/videojs-ass@0.8.0/src/videojs.ass.css
// @resource vjsASSJS https://cdn.jsdelivr.net/npm/videojs-ass@0.8.0/src/videojs.ass.js
// @resource switcherCSS https://cdn.jsdelivr.net/npm/videojs-http-source-selector@1.1.6/dist/videojs-http-source-selector.min.css
// @resource imaCSS https://cdn.jsdelivr.net/npm/videojs-ima@1.5.1/dist/videojs.ima.min.css
// @resource adData https://static.crunchyroll.com/config/cx-vilos-ads/558.json?nnn=2
// @version 2.1.3
// @grant none
// @run-at document-start
// @no-frames
// ==/UserScript==
window.videojs = videojs;
//As we're loading from document-start, it will be much harder to get access to the page's "built in" libjass variable, so we'll set up our own.
if(!window.libjass) window.libjass = libjass;
//Load needed CSS.
createCSS(GM_getResourceURL("vjsCSS"));
createCSS(GM_getResourceURL("libjassCSS"));
createCSS(GM_getResourceURL("vjsASSCSS"));
createCSS(GM_getResourceURL("imaCSS"));
createCSS(GM_getResourceURL("switcherCSS"));
//Adding custom stylesheet after video is initialized so that the "default" stylesheet doesn't override it
var newStyleSheet = document.createElement("style");
newStyleSheet.rel = "stylesheet";
newStyleSheet.innerHTML = `.vjs-volume-panel.vjs-volume-panel-horizontal
{
width: 9em;
}
.vjs-volume-panel .vjs-volume-control.vjs-volume-horizontal
{
width: 5em;
height: 3em;
padding-right: 10px;}
.vjs-volume-panel .vjs-volume-control
{
opacity: 1 !important;
}
.video-js .vjs-control-bar
{
background-color:#333;
}
.video-js .vjs-play-progress, .video-js .vjs-volume-level, .video-js .vjs-load-progress div
{
background-color:#f7931e;
}
.video-js .vjs-current-time
{
display:block;
padding-right: 0;
}
.video-js .vjs-time-divider
{
display:block;
}
.video-js .vjs-duration
{
display:block;
padding-left: 0;
}
.newMarker
{
width: 5px;
height: 100%;
background-color: white;
position: absolute;
}`;
document.head.appendChild(newStyleSheet);
//@require won't really work for some of the plugins, so instead we'll load it in the page.
function loadPlugins()
{
return Promise.all([createJS(GM_getResourceURL("vjsASSJS")),
//createJS(GM_getResourceURL("qualityLevelsJS")),
//createJS(GM_getResourceURL("switcherJS")),
createJS("https://imasdk.googleapis.com/js/sdkloader/ima3.js")]);
function createJS(scriptPath)
{
return new Promise(function(resolve)
{
var newScript = document.createElement("script");
newScript.type = "text/javascript";
newScript.src = scriptPath;
newScript.onload = resolve;
document.head.appendChild(newScript);
})
}
}
//Find the script that powers the embedSWF function so we can overwrite. This is why the script is set to load at document-start. This way, we have access to the function parameters, and more importantly, the function can be overwritten before the Flash plugin has a chance to load.
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(findVilosScript);
});
});
var config = { childList: true, subtree: true };
observer.observe(document, config);
var callbackCount = 0;
var lastPing = 0;
var pingIntervals = [];
var previousTime = 0;
var elapsed = 0;
var affiliateCode = "";
for(var i = 0; i < document.scripts.length; i++)
{
findVilosScript(document.scripts[i]);
}
function findVilosScript(start)
{
if(start.nodeName == "SCRIPT" && (start.src.includes("https://www.crunchyroll.com/versioned_assets/js/components/vilos_player") || start.src.includes("https://www.crunchyroll.com/versioned_assets/js/modules/view_templates/affiliate_iframe_embed")))
{
observer.disconnect();
if(window.VilosPlayer) redoFunction();
else start.addEventListener("load",function()
{
redoFunction();
});
function redoFunction()
{
window.VilosPlayer = (function () {
var orig_function = window.VilosPlayer;
return function () {
orig_function.apply(this);
this.load = function(container)
{
var baseID = "showmedia_video_player";
var placeholder = document.getElementById(baseID);
var newVideo = document.createElement("video");
newVideo.id = baseID;
newVideo.className = "video-js vjs-default-skin";
newVideo.controls = true;
newVideo.style.width = "100%";
newVideo.style.height = "100%";
newVideo.style.visibility = "visible";
newVideo.style.overflow = "hidden";
placeholder.parentNode.replaceChild(newVideo,placeholder);
var config = this.config;
Promise.all([getAdData(),loadPlugins()]).then(results => results[0]).then(function(adData)
{
var mediaID = config.analytics.legacy.media_id;
var autoplay = config.player.autoplay;
var streamFile = config.media.streams.find(stream => stream.format == "adaptive_hls" && (stream.hardsub_lang == null || stream.hardsub_lang == config.player.language));
var subtitles = config.media.subtitles;
var initialVolume = config.player.preferred_volume;
var duration = config.media.metadata.duration / 1000;
//affiliateCode = config.getElementsByTagName("default:affiliateCode")[0].textContent;
var streamObject = {};
streamObject.media_id = config.analytics.legacy.media_id;
streamObject.video_encode_id = config.analytics.legacy.video_encode_id;
streamObject.media_type = config.analytics.legacy.media_type;
//pingIntervals = config.getElementsByTagName("default:pingBackIntervals")[0].textContent.split(" ");
if(config.player.autoplay) newVideo.autoplay = true;
var adSlots = config.media.ad_breaks;
var player = window.videojs(baseID, {
sources: [
{src: streamFile.url, type: 'application/x-mpegURL'}
],
poster: config.media.thumbnail.url,
plugins: {
httpSourceSelector:
{
default: 'auto'
}
},
controlBar: {
children: [
'playToggle',
'progressControl',
'currentTimeDisplay',
'timeDivider',
'durationDisplay',
'playbackRateMenuButton',
'chaptersButton',
'subtitlesButton',
'captionsButton',
'httpSourceSelector',
'fullscreenToggle',
'volumePanel'
]
}},function()
{
jumpAhead();
if(autoplay) player.play();
if(subtitles.length > 0)
{
var firstSub = subtitles.find(sub => sub.language == config.player.language) || subtitles[0];
var vjs_ass = player.ass({
"src": [firstSub.url],
"label": firstSub.title,
"srclang": firstSub.language,
"enableSvg": false,
"delay": 0
});
for(var s = 0; s < subtitles.length; s++)
{
if(subtitles[s] == firstSub) continue;
vjs_ass.loadNewSubtitle(subtitles[s].url,subtitles[s].title,subtitles[s].language,false);
}
}
});
if(adSlots && adSlots.length > 0)
{
window.EVS = {};
window.EVS.player = player;
window.EVS.config = config;
window.vilos = {};
window.vilos.config = config;
function substituteParams(url)
{
for(var m = 0; m < adData.setup.maps.length; m++)
{
if(adData.setup.maps[m].name == "adBreakType") continue;
if(typeof adData.setup.maps[m].callback == "string")
{
if(adData.setup.maps[m].callback.includes("EVS.adService")) continue;
if(adData.setup.maps[m].callback.startsWith("function")) url = url.replace("{" + adData.setup.maps[m].name + "}", eval("(" + adData.setup.maps[m].callback + ")();"));
else url = url.replace("{" + adData.setup.maps[m].name + "}", eval(adData.setup.maps[m].callback));
}
}
return url;
}
var slots = adData.setup.creatives;
var midrollGroupCount = 0;
var markers = [];
var vmapString = '<\?xml version="1.0" encoding="UTF-8"?><vmap:VMAP xmlns:vmap="http://www.iab.net/videosuite/vmap" version="1.0">';
for(var s = 0; s < adSlots.length; s++)
{
var adUrls = Array.from(slots,ad => ad.resource);
var selectedUrl = "";
switch(adSlots[s].type)
{
case "preroll":
var slotCount = adData.setup.placements[0].context.preroll.adSlots;
for(var a = 0; a < slotCount; a++)
{
var prerollSlots = slots.filter(slot => slot.resource.toLowerCase().includes("tpcl=preroll"));
var slot = prerollSlots[prerollSlots.length - 1];
selectedUrl = substituteParams(slot.resource);
vmapString += '<vmap:AdBreak timeOffset="start" breakId="preroll"><vmap:AdSource id="preroll-ad-1" allowMultipleAds="false" followRedirects="true"><vmap:AdTagURI templateType="vast2"><![CDATA[' + selectedUrl + ']]></vmap:AdTagURI></vmap:AdSource></vmap:AdBreak>';
}
break;
case "midroll":
var adTime = adSlots[s].offset / 1000;
var midSlotCount = adData.setup.placements[0].context.midroll.adSlots;
if(!markers.includes(adTime))
{
markers.push(adTime);
var newMarker = document.createElement("div");
newMarker.className = "newMarker";
newMarker.style.left = ((adTime / duration) * 100) - 0.5 + "%";
player.el().querySelector('.vjs-progress-holder').appendChild(newMarker);
}
var minutes = Math.floor(adTime / 60);
var seconds = adTime % 60;
var hours = Math.floor(adTime / 3600);
function padToTwo(number)
{
return ("0" + number).slice(-2);
}
var timeToReal = padToTwo(hours) + ":" + padToTwo(minutes) + ":" + padToTwo(seconds) + ".000";
var midrollSlots = slots.filter(slot => slot.resource.toLowerCase().includes("tpcl=midroll"));
var midSlot = midrollSlots[midrollSlots.length - 1];
selectedUrl = substituteParams(midSlot.resource);
for(var m = 0; m < midSlotCount; m++)
{
vmapString += '<vmap:AdBreak timeOffset="' + timeToReal + '" breakId="midroll-' + (midrollGroupCount + 1) + '"><vmap:AdSource id="midroll-' + (midrollGroupCount + 1) + '-ad-' + (m + 1) + '" allowMultipleAds="false" followRedirects="true"><vmap:AdTagURI templateType="vast2"><![CDATA[' + selectedUrl + ']]></vmap:AdTagURI></vmap:AdSource></vmap:AdBreak>';
}
midrollGroupCount++;
break;
default:
break;
}
}
vmapString += '</vmap:VMAP>';
var options = {
adsResponse: vmapString,
vpaidMode: google.ima.ImaSdkSettings.VpaidMode.INSECURE
};
player.ima(options);
}
player.volume(initialVolume);
player.on("seeking", function()
{
previousTime = this.currentTime();
});
player.on("volumechange", function()
{
localStorage.setItem('vilosPreferredVolume',player.volume().toFixed(2));
});
player.on("timeupdate", function()
{
if(!player.seeking())
{
var delta = this.currentTime() - previousTime;
elapsed += delta;
previousTime = this.currentTime();
testPing();
}
});
function jumpAhead()
{
var startTime = config.player.start_offset;
if(startTime > 0) player.currentTime(startTime / 1000);
previousTime = player.currentTime();
}
function testPing()
{
if((elapsed * 1000) >= 30)
{
ping(streamObject,(elapsed * 1000),player.currentTime());
elapsed -= 30;
}
}
});
};
};
})();
//In Chrome, the function replacing will have come "too late", and embedSWF will have to be called again.
var initScript = Array.prototype.find.call(document.scripts, script => script.textContent.includes("vilosPlayer.load"));
if(initScript)
{
var newScript = document.createElement("script");
newScript.innerHTML = initScript.innerHTML;
var parentNode = initScript.parentNode;
parentNode.replaceChild(newScript,initScript);
}
}
}
}
function setData(newCallCount,newPing)
{
callbackCount = newCallCount;
lastPing = newPing;
}
function createCSS(css)
{
var newStyleSheet = document.createElement("link");
newStyleSheet.rel = "stylesheet";
newStyleSheet.href = css;
document.head.appendChild(newStyleSheet);
}
function getAdData()
{
return new Promise(function(resolve,reject)
{
var config = new XMLHttpRequest();
config.onload = function()
{
if(this.status == 200) resolve(this.response);
else if(this.status == 502) resolve(getAdData());
else reject(this);
};
config.onerror = reject;
config.open("GET", GM_getResourceURL("adData"), true);
config.responseType = "json";
config.send();
});
}
function ping(streamData, newLastPing, playhead)
{
var newCallCount = callbackCount + 1;
var sinceLastPing = newLastPing - lastPing;
sendPing(streamData,newCallCount,sinceLastPing,playhead);
setData(newCallCount,newLastPing);
}
function sendPing(entry, callCount, timeSinceLastPing, playhead)
{
var params = new URLSearchParams();
params.set("current_page",window.location.href);
params.set("req","RpcApiVideo_VideoView");
params.set("media_id",entry.media_id);
params.set("video_encode_id",entry.video_encode_id);
params.set("media_type",entry.media_type);
params.set("h",entry.ping_back_hash);
params.set("ht",entry.ping_back_hash_time);
params.set("cbcallcount",callCount);
params.set("cbelapsed",Math.floor(timeSinceLastPing / 1000));
if(!isNaN(playhead)) params.set("playhead",playhead);
if(affiliateCode) params.set("affiliate_code",affiliateCode);
var ping = new XMLHttpRequest();
ping.open("POST", "/ajax/", true);
ping.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
ping.send(params);
}
function GM_getResourceURL(resourceName)
{
if(GM_info.script.resources[resourceName]) return GM_info.script.resources[resourceName].url;
else
{
//The "built in" mimetype tends to be inaccurate, so we're doing something simpler to determine the mimetype of the resource
var resourceObject = GM_info.script.resources.find(resource => resource.name == resourceName);
var mimetype;
if(resourceObject.url.endsWith(".swf")) mimetype = "application/x-shockwave-flash";
else mimetype = resourceObject.meta;
var dataURL = "data:" + mimetype + "," + encodeURIComponent(resourceObject.content);
return dataURL;
}
}