Greasy Fork

Greasy Fork is available in English.

Crunchyroll HTML5

Replaces Crunchyroll's HTML5 player with an improved version

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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/[email protected]/dist/videojs-http-streaming.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/dist/videojs-contrib-quality-levels.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/src/videojs.ass.css
// @resource    vjsASSJS https://cdn.jsdelivr.net/npm/[email protected]/src/videojs.ass.js
// @resource    switcherCSS https://cdn.jsdelivr.net/npm/[email protected]/dist/videojs-http-source-selector.min.css
// @resource    imaCSS https://cdn.jsdelivr.net/npm/[email protected]/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
// ==/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"?><vmap:VMAP xmlns:vmap="http://www.iab.net/videosuite/vmap" version="1.0">';
								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 += '<vmap:AdBreak timeOffset="start" breakId="preroll"><vmap:AdSource id="preroll-ad-1" allowMultipleAds="false" followRedirects="true"><vmap:AdTagURI templateType="vast2"><![CDATA[' + selectedUrl + ']]></vmap:AdTagURI></vmap:AdSource></vmap:AdBreak>';
											}
											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 += '<vmap:AdBreak timeOffset="' + timeToReal + '" breakId="midroll-' + (midrollGroupCount + 1) + '"><vmap:AdSource id="midroll-' + (midrollGroupCount + 1) + '-ad-' + (m + 1) + '" allowMultipleAds="false" followRedirects="true"><vmap:AdTagURI templateType="vast2"><![CDATA[' + selectedUrl + ']]></vmap:AdTagURI></vmap:AdSource></vmap:AdBreak>';
											}

											midrollGroupCount++;
											break;
										default:
											break;
									}
								}

								vmapString += '</vmap:VMAP>';

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