Crunchyroll HTML5

Replaced Crunchyroll's Flash player with an HTML5 equivalent

目前为 2018-01-22 提交的版本。查看 最新版本

// ==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.6.1
// @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.3783bbbc.js"))
	{
		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 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];
							}
						}
					});
				});
			});
		};
	}
}

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或关注我们的公众号极客氢云获取最新地址