// ==UserScript==
// @name Crunchyroll HTML5
// @namespace DoomTay
// @description Replaced Crunchyroll's Flash player with an HTML5 equivalent
// @include http://www.crunchyroll.com/*
// @include https://www.crunchyroll.com/*
// @require https://cdn.rawgit.com/peterolson/BigInteger.js/441ca6ed02655abc778beb0baf07259f6912018e/BigInteger.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.js
// @require https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.5/pako.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/aes-js/3.1.0/index.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/video.js/5.20.1/video.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-hls/5.8.3/videojs-contrib-hls.min.js
// @require https://cdn.rawgit.com/Arnavion/libjass/b13173112df83073e03fdd209b87de61f7eb7726/demo/libjass.js
// @require https://cdnjs.cloudflare.com/ajax/libs/videojs-vast-vpaid/2.0.2/videojs_5.vast.vpaid.min.js
// @resource vpaidCSS https://cdnjs.cloudflare.com/ajax/libs/videojs-vast-vpaid/2.0.2/videojs.vast.vpaid.min.css
// @resource libjassCSS https://cdn.rawgit.com/Arnavion/libjass/b13173112df83073e03fdd209b87de61f7eb7726/demo/libjass.css
// @resource vjsASSCSS https://cdn.rawgit.com/SunnyLi/videojs-ass/4548378156565f230f6f1c64fa7b20a65baa363d/src/videojs.ass.css
// @resource vjsASSJS https://cdn.rawgit.com/SunnyLi/videojs-ass/4548378156565f230f6f1c64fa7b20a65baa363d/src/videojs.ass.js
// @version 0.9.2
// @grant none
// @run-at document-start
// @no-frames
// ==/UserScript==
//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;
//Since the videojs ASS plugin relies on libjass, loading it with @require won't really work, so instead we'll load it in the page.
function loadPlugin()
{
return new Promise(function(resolve,reject) {
var newScript = document.createElement("script");
newScript.type = "text/javascript";
newScript.src = GM_getResourceURL("vjsASSJS");
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.
function findScript()
{
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
for(var i = 0; i < mutation.addedNodes.length; i++)
{
findSWFScript(mutation.addedNodes[i]);
}
});
});
var config = { childList: true, subtree: true };
observer.observe(document, config);
for(var i = 0; i < document.scripts.length; i++)
{
findSWFScript(document.scripts[i]);
}
function findSWFScript(start)
{
if(start.nodeName == "SCRIPT" && start.src.includes("www/view/showmedia"))
{
observer.disconnect();
swfobject.embedSWF = function(swf,id,width,height,version,downloadURL,params)
{
var placeholder = document.getElementById(id);
var newVideo = document.createElement("video");
newVideo.id = id;
newVideo.className = "video-js vjs-default-skin";
newVideo.controls = true;
newVideo.width = width;
newVideo.height = height;
placeholder.parentNode.replaceChild(newVideo,placeholder);
var configURL = decodeURIComponent(params.config_url);
getConfig(configURL).then(function(config)
{
newVideo.poster = config.getElementsByTagName("default:backgroundUrl")[0].textContent;
var autoplay = config.getElementsByTagName("default:isAutoPlay")[0].textContent == 1;
var streamFile = config.querySelector("stream_info").querySelector("file").textContent;
var subtitleTag = config.querySelector("subtitle:not([link])");
var scriptObject = subtitleTag ? parseSubtitles(subtitleTag) : null;
var adSlots = config.getElementsByTagName("adSlots")[0];
loadPlugin().then(() =>
{
window.videojs(id, {
sources: [
{src: streamFile,type: 'application/x-mpegURL'}
],
controlBar: {
children: [
'playToggle',
'progressControl',
'currentTimeDisplay',
'timeDivider',
'durationDisplay',
'liveDisplay',
'customControlSpacer',
'playbackRateMenuButton',
'chaptersButton',
'subtitlesButton',
'captionsButton',
'fullscreenToggle',
'volumeMenuButton'
]
}}, function(){
var player = this;
//Load needed CSS.
createCSS(GM_getResourceURL("vpaidCSS"));
createCSS(GM_getResourceURL("libjassCSS"));
createCSS(GM_getResourceURL("vjsASSCSS"));
//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-menu-button.vjs-menu-button-inline\n\
{\n\
width: 12em;\n\
}\n\
.vjs-volume-menu-button.vjs-menu-button-inline .vjs-menu\n\
{\n\
opacity: 1;\n\
}\n\
.video-js .vjs-control-bar\n\
{\n\
background-color:#333;\n\
}\n\
.video-js .vjs-play-progress, .video-js .vjs-volume-level, .video-js .vjs-progress-holder, .video-js .vjs-load-progress div\n\
{\n\
background-color:#f7931e;\n\
}\n\
.video-js .vjs-current-time\n\
{\n\
display:block;\n\
padding-right: 0;\n\
}\n\
.video-js .vjs-time-divider\n\
{\n\
display:block;\n\
}\n\
.video-js .vjs-duration\n\
{\n\
display:block;\n\
padding-left: 0;\n\
}";
document.head.appendChild(newStyleSheet);
if(adSlots)
{
var slots = adSlots.getElementsByTagName("adSlot");
var adTags = Array.from(slots[0].getElementsByTagName("vastAd"),ad => ad.getAttribute("url"));
//At the moment, the VAST plugin can only handle one ad.
var adUrl = adTags.find(ad => ad.includes("backup3"));
if(adUrl)
{
var vastAd = player.vastClient({
"adTagUrl": adUrl,
"adsCancelTimeout": 5000,
"adsEnabled": true
});
}
}
if(scriptObject)
{
var ASSFile = new ASSObject(scriptObject);
var subtitleBlob = URL.createObjectURL(new Blob([ASSFile.toString()], {type : "text/plain"}));
var vjs_ass = player.ass({
"src": [subtitleBlob],
"enableSvg": false,
"delay": 0
});
}
if(autoplay) player.play();
});
});
});
};
}
}
}
findScript();
function createCSS(css)
{
var newStyleSheet = document.createElement("link");
newStyleSheet.rel = "stylesheet";
newStyleSheet.href = css;
document.head.appendChild(newStyleSheet);
}
function parseSubtitles(subtitles)
{
var iv = bytesToNumbers(atob(subtitles.getElementsByTagName("iv")[0].textContent));
var subData = bytesToNumbers(atob(subtitles.getElementsByTagName("data")[0].textContent));
var id = parseInt(subtitles.getAttribute("id"));
var key = createKey(id);
//CryptoJS's AES decrypting cuts off the resulting string sometimes, so we're using something else instead.
var aesCbc = new aesjs.ModeOfOperation.cbc(bytesToNumbers(key.toString(CryptoJS.enc.Latin1)), iv);
var decrypted = aesCbc.decrypt(subData);
var deflated = pako.inflate(decrypted, {to: "string"});
var script = new DOMParser().parseFromString(deflated,"text/xml").querySelector("subtitle_script");
return script;
function bytesToNumbers(bytes)
{
return Uint8Array.from(bytes,(letter,i) => bytes.charCodeAt(i));
}
function createKey(id)
{
function magic()
{
var hash = bigInt(88140282).xor(id).toJSNumber();
var multipliedHash = bigInt(hash).multiply(32).toJSNumber();
return bigInt(hash).xor(hash >> 3).xor(multipliedHash).toJSNumber();
}
var hash = "$&).6CXzPHw=2N_+isZK" + magic();
var shaHashed = CryptoJS.SHA1(hash);
var keyString = shaHashed.toString(CryptoJS.enc.Latin1);
var paddedKey = keyString + "\u0000".repeat(32 - keyString.length);
var recodedKey = CryptoJS.enc.Latin1.parse(paddedKey);
return recodedKey;
}
}
function getConfig(configURL)
{
return new Promise(function(resolve,reject)
{
var config = new XMLHttpRequest();
config.onload = function()
{
resolve(this.response);
};
config.onerror = reject;
config.open("POST", configURL, true);
config.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
config.responseType = "document";
config.send("current_page=" + window.location.href);
});
}
function ASSObject(scriptSource)
{
this.events = [];
var styles = scriptSource.querySelector("styles").children;
var events = scriptSource.querySelector("events").children;
this.id = scriptSource.getAttribute("id");
this.title = scriptSource.getAttribute("title");
this.langCode = scriptSource.getAttribute("lang_code");
this.langString = scriptSource.getAttribute("lang_string");
this.playResX = scriptSource.getAttribute("play_res_x");
this.playResY = scriptSource.getAttribute("play_res_y");
this.created = scriptSource.getAttribute("created");
this.wrapStyle = scriptSource.getAttribute("wrap_style");
this.styles = {};
var newStyle = document.createElement("style");
document.head.appendChild(newStyle);
for(var s = 0; s < styles.length; s++)
{
var styleObject = {};
styleObject.name = this.formatClass(styles[s].getAttribute("name"));
styleObject.fontName = styles[s].getAttribute("font_name");
styleObject.fontSize = styles[s].getAttribute("font_size");
styleObject.italic = styles[s].getAttribute("italic");
styleObject.bold = styles[s].getAttribute("bold");
styleObject.underline = styles[s].getAttribute("underline");
styleObject.strikeout = styles[s].getAttribute("strikeout");
styleObject.primaryColor = styles[s].getAttribute("primary_colour");
styleObject.secondaryColor = styles[s].getAttribute("secondary_colour");
styleObject.outlineColor = styles[s].getAttribute("outline_colour");
styleObject.backColor = styles[s].getAttribute("back_colour");
styleObject.scaleX = styles[s].getAttribute("scale_x");
styleObject.scaleY = styles[s].getAttribute("scale_y");
styleObject.spacing = styles[s].getAttribute("spacing");
styleObject.angle = styles[s].getAttribute("angle");
styleObject.borderStyle = styles[s].getAttribute("border_style");
styleObject.outline = styles[s].getAttribute("outline");
styleObject.shadow = styles[s].getAttribute("shadow");
styleObject.alignment = styles[s].getAttribute("alignment");
styleObject.marginL = styles[s].getAttribute("margin_l");
styleObject.marginR = styles[s].getAttribute("margin_r");
styleObject.marginV = styles[s].getAttribute("margin_v");
styleObject.encoding = styles[s].getAttribute("encoding");
this.styles[styleObject.name] = styleObject;
}
for(var e = 0; e < events.length; e++)
{
var parsedEvent = {};
parsedEvent.id = parseInt(events[e].getAttribute("id"));
parsedEvent.start = events[e].getAttribute("start");
parsedEvent.end = events[e].getAttribute("end");
parsedEvent.style = events[e].getAttribute("style");
parsedEvent.name = events[e].getAttribute("name");
parsedEvent.marginL = events[e].getAttribute("margin_l");
parsedEvent.marginR = events[e].getAttribute("margin_r");
parsedEvent.marginV = events[e].getAttribute("margin_v");
parsedEvent.text = "{\shad3}" + events[e].getAttribute("text");
parsedEvent.effect = events[e].getAttribute("effect");
this.events.push(parsedEvent);
}
}
ASSObject.prototype.constructor = ASSObject;
ASSObject.prototype.toString = function()
{
var string = "[Script Info]\n";
string += "Title: " + this.title + "\n";
string += "ScriptType: v4.00+\n";
string += "WrapStyle: " + this.wrapStyle + "\n";
string += "PlayResX: " + this.playResX + "\n";
string += "PlayResY: " + this.playResY + "\n";
string += "Subtitle ID: " + this.id + "\n";
string += "Language: " + this.langString + "\n";
string += "Created: " + this.created + "\n\n";
string += "[V4+ Styles]\n";
string += "Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding\n";
for(var style in this.styles)
{
var currentStyle = this.styles[style];
string += "Style: ";
string += currentStyle.name + ",";
string += currentStyle.fontName + ",";
string += currentStyle.fontSize + ",";
string += currentStyle.primaryColor + ",";
string += currentStyle.secondaryColor + ",";
string += currentStyle.outlineColor + ",";
string += currentStyle.backColor + ",";
string += currentStyle.bold + ",";
string += currentStyle.italic + ",";
string += currentStyle.underline + ",";
string += currentStyle.strikeout + ",";
string += currentStyle.scaleX + ",";
string += currentStyle.scaleY + ",";
string += currentStyle.spacing + ",";
string += currentStyle.angle + ",";
string += currentStyle.borderStyle + ",";
string += currentStyle.outline + ",";
string += currentStyle.shadow + ",";
string += currentStyle.alignment + ",";
string += currentStyle.marginL + ",";
string += currentStyle.marginR + ",";
string += currentStyle.marginV + ",";
string += currentStyle.encoding;
string += "\n";
}
string += "\n";
string += "[Events]\n";
string += "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n";
for(var e = 0; e < this.events.length; e++)
{
string += "Dialogue: 0,";
string += this.events[e].start + ",";
string += this.events[e].end + ",";
string += this.formatClass(this.events[e].style) + ",";
string += this.events[e].name + ",";
string += this.events[e].marginL + ",";
string += this.events[e].marginR + ",";
string += this.events[e].marginV + ",";
string += this.events[e].effect + ",";
string += this.events[e].text;
string += "\n";
}
return string;
};
ASSObject.prototype.formatClass = function(className)
{
return className.replace(/ /g,"_");
};
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 mimetype = resourceName.endsWith(".js") ? "text/javascript" : "text/css";
var resourceObject = GM_info.script.resources.find(resource => resource.name == resourceName);
var dataURL = "data:" + mimetype + "," + encodeURIComponent(resourceObject.content);
return dataURL;
}
}