Crunchyroll HTML5

Replaces Crunchyroll's HTML5 player with an improved version

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