8chan External Sounds

Plays audio associated with images on 8chan.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name 8chan External Sounds
// @namespace lig
// @description Plays audio associated with images on 8chan.
// @author Bakugo + MFG
// @version 1.7.2
// @match *://8chan.cc/*
// @match *://8chan.moe/*
// @match *://8chan.se/*
// @grant GM_xmlhttpRequest
// @run-at document-start
// ==/UserScript==

const debug_GM_fetch = false

function parseHeaders(responseHeaders) {
	let head = new Headers()
	let pairs = responseHeaders.trim().split('\n')
	pairs.forEach(function(header) {
		let split = header.trim().split(':')
		let key = split.shift().trim()
		let value = split.join(':').trim()
		try {
			head.append(key, value)
		} catch(e) {
			console.error(e);
		}
	})
	return head
}

function GM_fetch(url, options = {}) {
	return new Promise((res, rej) => {
		const host = new URL(url).hostname;
		options.url = url;
		options.method = options.method || 'GET';
		options.responseType = options.responseType || 'text';
		options.headers = options.headers || {};
		//options.headers.userAgent
		Object.assign(options.headers, {
			accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,audio/mpeg,*/*;q=0.8",
			"accept-encoding": "gzip, deflate, br",
			"accept-language": "en-US,en;q=0.5",
			"alt-used": host,
			"cache-control": "no-cache",
			connection: "keep-alive",
			host: host,
			pragma: "no-cache",
			"sec-fetch-dest": "document",
			"sec-fetch-mode": "navigate",
			"sec-fetch-site": "none",
			"sec-fetch-user": "?1",
			"upgrade-insecure-requests": "1",
		})
		options.onload = _res => {
			const parsedHeaders = parseHeaders(_res.responseHeaders);
			if(debug_GM_fetch) {
				console.log('parsedHeaders', parsedHeaders);
				console.log('response', _res);
			}
			const response = new Response(_res.response, {
				status: _res.status,
				statusText: _res.statusText,
				headers: parsedHeaders
			})
			Object.defineProperty(response, "url", { value: url });
			res(response);
		};

		options.onerror = function() {
			setTimeout(function() {
				rej(new TypeError('Network request failed'))
			}, 0)
		}

		options.ontimeout = function() {
			setTimeout(function() {
				rej(new TypeError('Network request timed out'))
			}, 0)
		}

		options.onabort = function() {
			setTimeout(function() {
				rej(new DOMException('Aborted', 'AbortError'))
			}, 0)
		}

		GM_xmlhttpRequest(options);
	});
}

function arrayBufferToBase64(buffer) {
	const bytes = new Uint8Array(buffer);
	const len = buffer.byteLength;
	let binary = "";
	for (let i = 0; i < len; i++) {
		binary += String.fromCharCode(bytes[i]);
	}
	return window.btoa(binary);
}

async function fetchSound(url) {
	const response = await GM_fetch(url, { responseType: "arraybuffer" })
    const arrayBuffer = await response.arrayBuffer()
    const type = response.headers.get('Content-Type')
	const b64 = arrayBufferToBase64(arrayBuffer)
    
    const src = `data:${type};base64,${b64}`
	return [src, type]
}

(function() {
	let doInit;
	let doParseFile;
	let doParseFiles;
	let doPlayFile;
	let doMakeKey;
	
	let allow;
	let players;
	
	allow = [
		"4cdn.org",
		"catbox.moe",
		"dmca.gripe",
		"lewd.se",
		"pomf.cat",
		"zz.ht"
	];
	
	document.addEventListener(
		"DOMContentLoaded",
		function (event) {
			setTimeout(
				function () {
					doInit();
				},
				(1)
			);
		}
	);
	
	doInit = function () {
		let observer;
		
		if (players) {
			return;
		}
		
		players = {};
		
		doParseFiles(document.body);
		
		observer =
			new MutationObserver(
				function (mutations) {
					mutations.forEach(
						function (mutation) {
							if (mutation.type === "childList") {
								mutation.addedNodes.forEach(
									function (node) {
										if (node.nodeType === Node.ELEMENT_NODE) {
											doParseFiles(node);
											doPlayFile(node);
										}
									}
								);
							}
						}
					);
				}
			);
		
		observer
			.observe(
				document.body,
				{
					childList: true,
					subtree: true
				}
			);
	};
	
	doParseFile = function (file) {
		let fileLink;
		let fileName;
		let key;
		let match;
		let player;
		let link;
		
		if (!file.classList.contains("uploadCell")) {
			return;
		}
		
		fileLink = file.querySelector(".originalNameLink");
		
		if (!fileLink) {
			return;
		}
		
		if (!fileLink.href) {
			return;
		}
		
		fileName = fileLink.textContent;
		
		if (!fileName) {
			return;
		}
		
		fileName = fileName.replace(/\-/, "/");
		
		key = doMakeKey(fileLink.href);
		
		if (!key) {
			return;
		}
		
		if (players[key]) {
			return;
		}
		
		match = fileName.match(/[\[\(\{](?:audio|sound)[ \=\:\|\$](.*?)[\]\)\}]/i);
		
		if (!match) {
			return;
		}
		
		link = match[1];
		
		if (link.includes("%")) {
			try {
				link = decodeURIComponent(link);
			} catch (error) {
				return;
			}
		}
		
		
		if (link.match(/^(https?\:)?\/\//) === null) {
			link = (location.protocol + "//" + link);
		}
		
		try {
			link = new URL(link);
		} catch (error) {
			return;
		}
		
		if (
			allow.some(
				function (item) {
					return (
						link.hostname.toLowerCase() === item ||
						link.hostname.toLowerCase().endsWith("." + item)
					);
				}
			) == false
		) {
			return;
		}

		if(key.endsWith('mp4') || key.endsWith('webm')) {
			const video = file.querySelector('video')
			const imgLink = file.querySelector('.imgLink')
			console.log('binding video soundpost', key, video, imgLink)
			imgLink.addEventListener('click', e => {
				doPlayFile(video)
			})
		}
		
		player = new Audio();
		
		player.fetched = false
		player.crossOrigin = 'anonymous';
		player.preload = "none";
		player.volume = 0.80;
		player.loop = true;
		
		player.src = link.href;
		
		players[key] = player;
	};
	
	doParseFiles = function (target) {
		target.querySelectorAll(".innerPost, .innerOP")
			.forEach(
				function (post) {
					if (post.parentElement.classList.contains("quoteTooltip")) {
						return;
					}
					
					if (!post.querySelector('.uploadCell')) {
						return;
					}
					
					post.querySelectorAll(".uploadCell")
						.forEach(
							function (file) {
								doParseFile(file);
							}
						);
				}
			);
	};
	
	doPlayFile = async function (target) {
		let key;
		let player;
		let interval;

		if (!(
			target.matches('video[controls="true"]') && target.parentElement.parentElement.matches('.uploadCell') ||
			target.matches(".imgExpanded") ||
			target.matches('img') && target.parentElement.matches('body') ||
			target.matches('video') && target.parentElement.matches('body')
		)) {
			return;
		}

		if (!target.src && !target.currentSrc) {
			return;
		}
		
		key = doMakeKey(target.src || target.currentSrc);
		
		if (!key) {
			return;
		}
		
		player = players[key];

		if (!player) {
			return;
		}

		if(target.matches('video[controls="true"]')) {
			console.log('found video soundpost')
		}

		console.log('players', players)

		if(target.matches(`.imgExpanded`)) {
			target.addEventListener('click', e => {
				let parent = target.parentElement
				setTimeout(() => {
					target.remove()
					parent.querySelector('img').removeAttribute('style')
				}, 50)
			})
		}

		if(!player.fetched) {
			if(!player.response)
				player.response = fetchSound(player.src)
			let [src, type] = await player.response
            player = new Audio()
            player.fetched = true
            player.response = true
            player.type = type
            player.src = src
			players[key] = player
		}

		if (!player.paused) {
			if (player.dataset.play == 1) {
				player.dataset.again = 1;
			} else {
				player.pause();
			}
		}
		
		if (player.dataset.play != 1) {
			player.dataset.play = 1;
			player.dataset.again = 0;
			player.dataset.moveTime = 0;
			player.dataset.moveLast = 0;
		}
		
		switch (target.tagName) {
			case "IMG":
				player.loop = true;
				
				if (player.dataset.again != 1) {
					player.currentTime = 0;
					player.play();
				}
				
				break;
			
			case "VIDEO":
				player.loop = false;
				player.currentTime = target.currentTime;
				player.play();
				break;
			
			default:
				return;
		}
		
		if (player.paused) {
			document.dispatchEvent(
				new CustomEvent(
					"CreateNotification",
					{
						bubbles: true,
						detail: {
							type: "warning",
							content: "Your browser blocked autoplay, click anywhere on the page to activate it and try again.",
							lifetime: 5
						}
					}
				)
			);
		}
		
		interval =
			setInterval(
				function () {
					if (document.body.contains(target) && !target.matches('[style$="display: none;"]')) {
						if (target.tagName === "VIDEO") {
							if (target.currentTime != (+player.dataset.moveLast)) {
								player.dataset.moveTime = Date.now();
								player.dataset.moveLast = target.currentTime;
							}
							
							if (player.duration != NaN) {
								if (
									target.paused == true ||
									target.duration == NaN ||
									target.currentTime > player.duration ||
									((Date.now() - (+player.dataset.moveTime)) > 300)
								) {
									if (!player.paused) {
										player.pause();
									}
								} else {
									if (
										player.paused ||
										Math.abs(target.currentTime - player.currentTime) > 0.100
									) {
										player.currentTime = target.currentTime;
									}
									
									if (player.paused) {
										player.play();
									}
								}
							}
						}
					} else {
						clearInterval(interval);
						
						if (player.dataset.again == 1) {
							player.dataset.again = 0;
						} else {
							player.pause();
							player.dataset.play = 0;
						}
					}
				},
				(1000/30)
			);
	};
	
	doMakeKey = function (link) {
		let match;
		match = link.match(/https\:\/\/8chan\.(?:cc|moe|se)\/\.media\/(.+?)\.(.+)$/);
		
		if (match) {
			return (match[1] + "." + match[2]);
		}
		
		return null;
	};
})();