Crunchyroll HTML5

Replaced Crunchyroll's Flash player with an HTML5 equivalent

目前为 2018-03-05 提交的版本。查看 最新版本

// ==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;
	}
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址