// ==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 // @downloadURL none // ==/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"?>'; 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 += ''; } 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 += ''; } midrollGroupCount++; break; default: break; } } vmapString += ''; 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; } }