// ==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/979795b450bcbc9d1d06accb6ab57417501edb08/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.6/pako_inflate.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.12.2/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 vjsCSS https://cdnjs.cloudflare.com/ajax/libs/video.js/5.20.1/video-js.min.css // @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.7 // @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; var subXSL = new DOMParser().parseFromString(` [Script Info] [V4+ Styles] Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding [Events] Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text `,"text/xml"); //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. var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(findSWFScript); }); }); 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 seeking = false; for(var i = 0; i < document.scripts.length; i++) { findSWFScript(document.scripts[i]); } function findSWFScript(start) { if(start.nodeName == "SCRIPT" && start.src.includes("http://static.ak.crunchyroll.com/versioned_assets/js/modules/www/application")) { observer.disconnect(); start.addEventListener("load",function() { 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 streamInfo = config.querySelector("stream_info"); var mediaID = config.getElementsByTagName("default:mediaId")[0].textContent; var autoplay = config.getElementsByTagName("default:isAutoPlay")[0].textContent == 1; var streamFile = streamInfo.querySelector("file").textContent; var subtitleTag = config.querySelector("subtitle:not([link])"); var scriptObject = subtitleTag ? parseSubtitles(subtitleTag) : null; var initialVolume = config.getElementsByTagName("default:initialVolume")[0].textContent; var initialMute = config.getElementsByTagName("default:initialMute")[0].textContent == "true"; var streamObject = {}; streamObject.media_id = mediaID; streamObject.video_encode_id = streamInfo.getElementsByTagName("video_encode_id")[0].textContent; streamObject.media_type = streamInfo.querySelector("media_type").textContent; streamObject.ping_back_hash = streamInfo.querySelector("pingback").querySelector("hash").textContent; streamObject.ping_back_hash_time = streamInfo.querySelector("pingback").querySelector("time").textContent; pingIntervals = config.getElementsByTagName("default:pingBackIntervals")[0].textContent.split(" "); 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("vjsCSS")); 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 && adSlots.children.length > 0) { 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 convertedSubs = convertSubFile(scriptObject); var subtitleBlob = URL.createObjectURL(new Blob([convertedSubs], {type : "text/plain"})); var vjs_ass = player.ass({ "src": [subtitleBlob], "label": scriptObject.getAttribute("title"), "srclang": scriptObject.getAttribute("lang_code").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 == scriptObject.getAttribute("lang_code").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 = parseSubtitles(response.children[0]); var convertedScript = convertSubFile(parsedSubtitle); var subtitleBlob = URL.createObjectURL(new Blob([convertedScript], {type : "text/plain"})); vjs_ass.loadNewSubtitle(subtitleBlob,parsedSubtitle.getAttribute("title"),parsedSubtitle.getAttribute("lang_code").substring(0,2),false); }; subs.open("GET", otherSubs[s].getAttribute("link"), true); subs.responseType = "document"; subs.send(); } } } player.volume(initialVolume / 100); if(initialMute) player.muted(true); jumpAhead(); if(autoplay) player.play(); player.on("seeked", function() { seeking = false; previousTime = this.currentTime(); }); player.on("seeking", function() { seeking = true; }); player.on("timeupdate", function() { if(!seeking) { var delta = this.currentTime() - previousTime; //Hack to get around delta being unusual when video is seeking delta = Math.max(Math.min(delta,1),0); elapsed += delta; previousTime = this.currentTime(); testPing(); } }); function jumpAhead() { var startTime = config.getElementsByTagName("default:startTime")[0]; if(startTime && startTime.textContent > 0) player.currentTime(startTime.textContent); previousTime = player.currentTime(); } function testPing() { var currentInterval = Math.min(pingIntervals.length, callbackCount); if((elapsed * 1000) >= pingIntervals[currentInterval]) { ping(streamObject,(elapsed * 1000),player.currentTime()); elapsed -= (pingIntervals[currentInterval] / 1000); } } }); }); }); }; }); } } 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 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 recodedKey = CryptoJS.enc.Latin1.parse(keyString.padEnd(32,"\u0000")); 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 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); var ping = new XMLHttpRequest(); ping.open("POST", "/ajax/", true); ping.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); ping.send(params); } function convertSubFile(subs) { var xsltProcessor = new XSLTProcessor(); xsltProcessor.importStylesheet(subXSL); resultDocument = xsltProcessor.transformToFragment(subs, document); return resultDocument.textContent; } 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; } }