Crunchyroll HTML5

Replaced Crunchyroll's Flash player with an HTML5 equivalent

Stan na 05-03-2018. Zobacz najnowsza wersja.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==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
// ==/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(`<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:fo="http://www.w3.org/1999/XSL/Format" >
<xsl:output method="text" omit-xml-declaration="yes" indent="no"/>
<xsl:strip-space elements="*"/>

<xsl:template match="subtitle_script">[Script Info]
<xsl:value-of select="concat('Title: ', @title,'&#xA;',
	'ScriptType: v4.00+','&#xA;',
	'WrapStyle: ', @wrap_style,'&#xA;',
	'PlayResX: ', @play_res_x,'&#xA;',
	'PlayResY: ', @play_res_y,'&#xA;',
	'Subtitle ID: ', @id,'&#xA;',
	'Language: ', @lang_string,'&#xA;',
	'Created: ', @created)"/>
<xsl:variable name="langCode" select="@lang_code"/>

[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
<xsl:for-each select="styles/style">
<xsl:variable name="formattedName" select="concat(translate(@name,' ','_'),'_',$langCode)"/>
<xsl:value-of select="concat('Style: ',
	$formattedName,',',
	@font_name,',',
	@font_size,',',
	@primary_colour,',',
	@secondary_colour,',',
	@outline_colour,',',
	@back_colour,',',
	@bold,',',
	@italic,',',
	@underline,',',
	@strikeout,',',
	@scale_x,',',
	@scale_y,',',
	@spacing,',',
	@angle,',',
	@border_style,',',
	@outline,',',
	@shadow,',',
	@alignment,',',
	@margin_l,',',
	@margin_r,',',
	@margin_v,',',
	@encoding,'&#xA;')"/>
</xsl:for-each>
[Events]
Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
<xsl:for-each select="events/event">
<xsl:variable name="formattedName" select="concat(translate(@style,' ','_'),'_',$langCode)"/>
<xsl:value-of select="concat('Dialogue: 0,',
	@start,',',
	@end,',',
	$formattedName,',',
	@name,',',
	@margin_l,',',
	@margin_r,',',
	@margin_v,',',
	@effect,',',
	@text,'&#xA;')"/>
</xsl:for-each>
</xsl:template>
</xsl:stylesheet>`,"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;
	}
}