// ==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.js // @require https://cdnjs.cloudflare.com/ajax/libs/videojs-contrib-hls/5.11.0/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.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/a884c6b8fcc8bab9e760214bb551601f54cd769f/src/videojs.ass.css // @resource vjsASSJS https://cdn.rawgit.com/SunnyLi/videojs-ass/a884c6b8fcc8bab9e760214bb551601f54cd769f/src/videojs.ass.js // @resource VPAIDSWF https://cdnjs.cloudflare.com/ajax/libs/videojs-vast-vpaid/2.0.2/VPAIDFlash.swf // @version 0.9.4 // @grant none // @run-at document-start // @no-frames // @downloadURL none // ==/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[0]; if(adUrl) { var vastAd = player.vastClient({ "adTagUrl": adUrl, "playAdAlways": true, "vpaidFlashLoaderPath": GM_getResourceURL("VPAIDSWF"), "adsEnabled": true }); player.on("vast.contentStart", function() { jumpAhead(); }); } } 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], "label": ASSFile.title, "srclang": ASSFile.langCode.substring(0,2), "enableSvg": false, "delay": 0 }); //Switching immediately on load doesn't immediately work for whatever reason. This gets around that player.on("vast.contentStart", function() { var currentTrack = Array.from(player.textTracks()).find(sub => sub.language == ASSFile.langCode.substring(0,2)); currentTrack.mode = "showing"; }); var otherSubs = config.querySelectorAll("subtitle[link]"); if(otherSubs) { for(var s = 0; s < otherSubs.length; s++) { if(otherSubs[s].id == scriptObject.id) continue; var subs = new XMLHttpRequest(); subs.onload = function () { var response = this.response; var parsedSubtitle = new ASSObject(parseSubtitles(response.children[0])); var subtitleBlob = URL.createObjectURL(new Blob([parsedSubtitle.toString()], {type : "text/plain"})); vjs_ass.loadNewSubtitle(subtitleBlob,parsedSubtitle.title,parsedSubtitle.langCode.substring(0,2),false) } subs.open("GET", otherSubs[s].getAttribute("link"), true); subs.responseType = "document"; subs.send(); } } } jumpAhead(); if(autoplay) player.play(); function jumpAhead() { if(/\?t=(\d+)/.test(window.location.href)) { var startTime = window.location.href.match(/\?t=(\d+)/)[1]; player.currentTime(startTime); } } }); }); }); }; } } } 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 = {}; 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 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; } }