Greasy Fork

Greasy Fork is available in English.

4chan X Thread Playback

Plays back threads on 4chan X

当前为 2021-12-22 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name 4chan X Thread Playback
// @namespace VSJPlus
// @license GNU GPLv3
// @description Plays back threads on 4chan X
// @version 1.0.2
// @match *://boards.4chan.org/*
// @match *://boards.4channel.org/*
// @run-at document-start
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js
// @grant GM.info
// ==/UserScript==
console.log('4chan X Playback');

(async function() {
	let isChanX;
	let fourChanXInitFinished = new Promise(res => {
		document.addEventListener(
			"4chanXInitFinished",
			function (event) {
				if (
					document.documentElement.classList.contains("fourchan-x") &&
					document.documentElement.classList.contains("sw-yotsuba")
				) {
					isChanX = true;
					res();
				}
			}
		);
	})

	async function appendStyle() {
		var head = document.head;

		if(!head) {
			head = await new Promise(res => {
				let obs = new MutationObserver(mutations => {
					for(let mutation of mutations) {
						if(!mutation.addedNodes || !mutation.addedNodes.length)
							continue;
						for(let node of mutation.addedNodes) {
							if(node.matches('head')) {
								obs.disconnect();
								res(node);
							}
						}
					}
				});
				obs.observe(document.documentElement, {childList: true});
			});
		}
		let link = document.createElement('link');
		link.rel = 'stylesheet';
		link.style = 'text/css';
		link.href = 'https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css';
		head.appendChild(link);

		var style = document.createElement('style');
		style.type = 'text/css';
		style.id = 'playbackStyle';

		style.innerHTML = `
			#playbackUI {
			    position: absolute;
			    top: 100%;
			    margin: 0 auto;
			    width: 90%;
			    max-width: 1080px;
			    box-sizing: border-box;
			    background-color: #282a2e;
			    opacity: 0.9;
			    transition: opacity 100ms;
			    display: none;
			    z-index: 1;
			    box-shadow: 0 0 10px #0008;
			    padding: 10px;
			    left: 0;
			    right: 0;
			}

			#playbackSlider {
			    background: #bbb;
			    border-color: #999;
			    cursor: pointer;
			}

			#playbackSlider:hover {
			    background: #c4c4c4;
			}

			#playbackUI:hover, #playbackUI:focus {
			    opacity: 1;
			}

			.playbackEnabled #playbackUI {
			    display: flex;
			    flex-direction: row;
			    align-items: center;
			}
			.playbackEnabled #playbackUI #playbackTimeContainer {
			    flex-shrink: 1;
			}

			.playbackEnabled #playbackUI #playbackSlider {
			    flex-grow: 1;
			    margin: 10px
			}

			#playbackInputContainer input {
			    width: 15px;
			    padding: 0;
			    margin: 0;
			    border: 0;
			    font-size: 12px;
			    font-weight: bold;
			}

			input#playbackInputYear {
			    width: 30px;
			}

			#playbackTimeContainer {
			    font-weight: bold;
			}

			#playbackDisplay > * > * {
			    cursor: pointer;
			}

			#playbackDisplay > * > *:hover {
			    color: #fff;
			}

			#playbackPauseResume {
			    width: 20px;
			    height: 27px;
			    text-align: center;
			    display: inline-block;
			    cursor: pointer;
			    position: relative;
			    color: #ccc;
			    transition: color 100ms;
			    vertical-align: center;
			}

			#playbackPauseResume:hover {
			    color: #fff;
			}

			#playbackPauseResume:before {
			    font-size: 20px;
			    display: block;
			    position: absolute;
			    user-select: none;
			    text-align: center;
			    top: -2px;
			}

			#playbackPauseResume:not(.pause):before {
			    content: 'II';
			    left: 2.5px;
			    font-weight: 900;
			}

			#playbackPauseResume.pause:before {
			    content: '';
			    width: 13px;
			    height: 15px;
			    background-color: #ccc;
			    left: 5px;
			    top: 6px;
			    clip-path: polygon(0 0, 13px 50%, 0 100%);
			}

			#playbackSkipBack,
			#playbackSkipAhead {
			    font-size: 20px;
			    font-weight: bold;
			    color: #ccc;
			    cursor: pointer;
			    transition: color 100ms;
			    position: relative;
			    width: 18px;
			    height: 27px;
			}

			#playbackSkipBack:before,
			#playbackSkipAhead:before {
			    top: -1px;
			}

			#playbackSkipBack {
			    transform-origin: 9px 15px;
			    margin-left: 5px;
			}

			#playbackSkipAhead {
			    transform-origin: 10px 15px;
			}

			#playbackSkipBack:hover {
			    color: #fff;
			}

			#playbackSkipAhead:hover {
			    color: #fff;
			}

			#playbackSkipBack:before {
			    content: '⭯';
			    position: absolute;
			    transition: transform 100ms;
			}

			#playbackSkipBack:active:before {
			    transform: rotate(-25deg);
			}

			#playbackSkipAhead:before {
			    content: '⭮';
			    position: absolute;
			    transition: transform 100ms;
			}

			#playbackSkipAhead:active:before {
			    transform: rotate(25deg);
			}

			#playbackSpeedDisplay {
			    margin-left: 2px;
			    font-weight: bold;
			    cursor: pointer;
			}

			#playbackSpeedDisplay:hover {
			    color: #fff;
			}

			#playbackSpeedInput {
			    width: 35px;
			    padding: 0;
			    margin: 0;
			    border: 0;
			    text-align: right;
			    font-weight: bold;
			    font-size: 12px;
			}

			#playbackSpeedInput:after {
			    content: 'x';
			}

			.playbackEnabled .playbackHidden {
			        display: none !important;
			}

			.playbackEnabled .backlink.playbackHidden + .hashlink {
			        display: none !important;
			}

			#playbackUI .ui-slider-handle {
			    border-radius: 10px;
			    background: #ccc !important;
			    cursor: pointer;
			}

			#playbackUI .ui-slider-handle.ui-state-hover,
			#playbackUI .ui-slider-handle.ui-state-active {
			    background: #fff !important;
			}

			#playbackDisplay > * > * {
			    display: inline-block;
			}

			@media only screen and (max-width: 316px) {
			    #playbackUI {
			        width: 100%;
			        margin: 0;
			    }
			}

			.adc-resp-bg {
			    display: none;
			}

			[tooltip] {
			    position: relative;
			}

			[tooltip]:hover:after {
			    content: attr(tooltip);
			    position: absolute;
			    background: #000;
			    padding: 3px;
			    top: 100%;
			    margin-top: 10px;
			    left: 50%;
			    transform: translateX(-50%);
			    border: 0.5px solid #ccc;
			    border-radius: 3px;
			    font-weight: normal;
			    font-size: 10px;
			    text-align: center;
			    z-index: 1;
			    pointer-events: none;
			    animation: tooltipFade 800ms;
			    color: #ccc;
			}

			@keyframes tooltipFade {
			    0% { opacity: 0; }
			    80% { opacity: 0; }
			    100% { opacity: 1; }
			}

			#playbackPauseResume.pause:hover:after {
			    content: 'Play';
			}

			#playbackToggle.loading {
			    opacity: 0.4;
			    cursor: wait;
			}
		`;

		head.appendChild(style);
	}

	let slider, scrubbing = false, seeking = false, playing = true, startUnix, currentUnix, maxUnix;

	const delay = ms => new Promise(r => setTimeout(r, ms));
	const isArchived = () => document.querySelector('#update-status').innerText == 'Archived';
	const $q = s => document.querySelector(s);
	const $qa = s => document.querySelectorAll(s);
	const $id = id => document.getElementById(id);
	const aF = () => new Promise(r => window.requestAnimationFrame(r));

	function updatePlaybackSub() {
		maxUnix = isArchived() ? parseInt($q('.thread > .postContainer:last-of-type .dateTime').dataset.utc):moment().unix();
		if(playing) currentUnix++;
		currentUnix = Math.min(currentUnix, maxUnix);
		slider.slider('option', 'max', maxUnix);
		slider.slider('option', 'value', currentUnix);
	}

	let lastUpdate, playbackSpeed = 1, correction = 0;
	const adjustedInterval = () => {
		let now = Date.now(), increment = 1000/playbackSpeed;
		if(!lastUpdate) lastUpdate = now - increment;
		correction = increment - (now - lastUpdate - correction);
		lastUpdate = now;
		return increment + correction;
	}
	async function updatePlayback() {
		await delay(1000 - Date.now()%1000);
		while(true) {
			if(!scrubbing) updatePlaybackSub();
			await delay(adjustedInterval());
		}
	}

	const playbackHiddenPosts = document.createElement('style');
	let lastHiddenPosts;
	async function updatePostVisibility() {
		let css = posts.filter(p => p.timestamp > currentUnix).map(p => p.selectors),
		newPosts = lastHiddenPosts != css.length;
		if(!newPosts) return;
		lastHiddenPosts = css.length;
		css = css.join(', ');
		css += '{ display: none !important; }';
		let scrollToBottom = false, docEl = document.documentElement;
		if(newPosts && autoScroll && (docEl.offsetHeight - (docEl.scrollTop + window.innerHeight)) < 100) {
			scrollToBottom = true;
		}
		playbackHiddenPosts.innerHTML = css;
		if(scrollToBottom) {
			await aF();
			docEl.scrollTop = docEl.offsetHeight - window.innerHeight;
		}
	}

	function updateDateTimeDisplay(unix) {
		let m = moment.unix(unix);
		[...$qa('#playbackDisplay [data-unit]')].forEach(e => (e.innerHTML = m.format(e.dataset.unit)));
	}

	const posts = [], nextInput = {
		playbackInputYear: 'playbackInputMonth',
		playbackInputMonth: 'playbackInputDay',
		playbackInputDay: 'playbackInputHours',
		playbackInputHours: 'playbackInputMinutes',
		playbackInputMinutes: 'playbackInputSeconds'
	}

	function getPostData(id) {
		if(!id) return null;
		let selectors = [
				`.postContainer[data-full-i-d="${id}"]`,
				`.backlink[href="#p${id.split('.')[1]}"]`
			];
		let postContainer = $q(selectors[0]);
		selectors.push(`${selectors[1]} + .hashlink`)
		return {
			selectors: selectors.map(s => 'html.playbackEnabled '+s).join(', '),
			timestamp: parseInt(postContainer.querySelector('.dateTime').dataset.utc)
		}
	}

	let autoScroll = false;

	function togglePlay(newPlaying) {
		if(newPlaying === undefined)
			newPlaying = !playing;
		playing = newPlaying;
		if(playing) $('#playbackPauseResume').removeClass('pause');
		else $('#playbackPauseResume').addClass('pause');
	}

	async function setupPlaybackToggle() {
		let threadingControl = document.querySelector('#threadingControl');
		if(!threadingControl) return;
		let autoScrollCheckbox = $q('input[name="Auto Scroll"]');
		autoScroll = autoScrollCheckbox.checked;
		$(autoScrollCheckbox).change(e => (autoScroll = autoScrollCheckbox.checked));
		threadingControl.parentNode
		.insertAdjacentHTML('afterend', '<label id="playbackToggle" class="entry"><input id="playbackToggleCheckbox" type="checkbox"> Playback</label>');
		let checkbox = document.querySelector('#playbackToggleCheckbox');
		checkbox.checked = document.documentElement.matches('.playbackEnabled');

		let toggle = $('#playbackToggle').hover(e => $(e.target).addClass('focused').siblings().removeClass('focused'));
		if(!isChanX) {
			toggle.addClass('loading');
			await fourChanXInitFinished;
			toggle.removeClass('loading');
		}

		$(checkbox).click(() => {
			let checked = document.querySelector('#playbackToggleCheckbox').checked;
			$(document.documentElement).toggleClass('playbackEnabled');
			togglePlay(checked);
		});
	}

	function setupPlaybackUI() {
		$q('#header-bar').insertAdjacentHTML('beforeend', `
			<div id="playbackUI">
				<div id="playbackTimeContainer">
					<div id="playbackInputContainer" class="playbackHidden">
						<div id="playbackInputDate">
							<input type="text" id="playbackInputYear" maxlength="4" data-unit="yyyy" tooltip="Change Year">/<input type="text" id="playbackInputMonth" maxlength="2" data-unit="MM" tooltip="Change Month">/<input type="text" id="playbackInputDay" maxlength="2" data-unit="DD" tooltip="Change Day">
						</div>
						<div id="playbackInputTime">
							<input type="text" id="playbackInputHours" maxlength="2" data-unit="HH" tooltip="Change Hour">:<input type="text" id="playbackInputMinutes" maxlength="2" data-unit="mm" tooltip="Change Minutes">:<input type="text" id="playbackInputSeconds" maxlength="2" data-unit="ss" tooltip="Change Seconds">
						</div>
					</div>
					<div id="playbackDisplay">
						<div id="playbackDisplayDate">
							<div id="playbackDisplayYear" data-unit="yyyy" tooltip="Change Year">----</div>/<div id="playbackDisplayMonth" data-unit="MM" tooltip="Change Month">--</div>/<div id="playbackDisplayDay" data-unit="DD" tooltip="Change Day">--</div>
						</div>
						<div id="playbackDisplayTime">
							<div id="playbackDisplayHours" data-unit="HH" tooltip="Change Hour">----</div>:<div id="playbackDisplayMinutes" data-unit="mm" tooltip="Change Minutes">--</div>:<div id="playbackDisplaySeconds" data-unit="ss" tooltip="Change Seconds">--</div>
						</div>
					</div>
				</div>
				<div id="playbackSlider"></div>
				<input id="playbackSpeedInput" class="playbackHidden" type="text" tooltip="Playback Speed">
				<div id="playbackSpeedDisplay" type="text" tooltip="Playback Speed">1x</div>
				<div id="playbackSkipBack" tooltip="Back 5s"></div>
				<div id="playbackPauseResume" tooltip="Pause"></div>
				<div id="playbackSkipAhead" tooltip="Skip 5s"></div>
			</div>
		`);
	}

	function updateCurrentTime(t) {
		currentUnix = Math.max(startUnix, Math.min(t, maxUnix));
		slider.slider('option', 'value', currentUnix);
	}

	async function waitForSelector(selector) {
		let result;
		do {
			if(result = document.querySelector(selector))
				return result;
			await delay(100);
		} while(1);
	}

	async function doInit() {
		console.log('Playback Init');
		let obs = new MutationObserver(e => {
			if(e[0].addedNodes.length) {
				setupPlaybackToggle();
			}
		});
		obs.observe(await waitForSelector('#shortcut-menu'), {childList: true});
		setupPlaybackToggle();

		playbackHiddenPosts.id = 'playbackHiddenPosts';
		document.head.appendChild(playbackHiddenPosts);
		appendStyle();

		await fourChanXInitFinished;

		posts.push(...[...$qa('.thread > .postContainer')].map(pc => getPostData(pc.dataset.fullID)));

		document.addEventListener('ThreadUpdate', e => {
			if(e.detail && e.detail.newPosts && e.detail.newPosts.length) {
				posts.push(...e.detail.newPosts.map(getPostData));
				updatePostVisibility();
			}
		});

		setupPlaybackUI();

		startUnix = parseInt($('.opContainer .dateTime').attr('data-utc'));
		currentUnix = isArchived() ? parseInt($('.postContainer:last-child .dateTime').attr('data-utc')):moment().unix();
		maxUnix = currentUnix;

		function renderPlayback(e, ui) {
			currentUnix = ui.value;
			updateDateTimeDisplay(currentUnix);
			updatePostVisibility();
		}

		slider = $('#playbackSlider').slider({
			min: startUnix,
			value: currentUnix,
			max: maxUnix,
			start: (e, ui) => (scrubbing = true),
			stop: (e, ui) => (scrubbing = false),
			animate: 100,
			change: renderPlayback,
			slide: renderPlayback
		});

		updatePlayback();

		$('#playbackPauseResume').click(e => {
			$(e.target).toggleClass('pause');
			playing = !playing;
		});

		let playbackInputContainer = $('#playbackInputContainer');
		let playbackDisplay = $('#playbackDisplay').click(e => {
			let unit = e.target.dataset.unit;
			let m = moment.unix(currentUnix);
			[...$qa('#playbackInputContainer input')].forEach(e => (e.value = m.format(e.dataset.unit)));
			swapTimeDisplayAndInput();
			let focusElement;
			if(unit) focusElement = $q('#playbackInputContainer [data-unit="'+unit+'"]');
			else focusElement = $q('#playbackInputYear');
			focusElement.focus();
			focusElement.setSelectionRange(0, focusElement.maxLength);
		});

		function swapTimeDisplayAndInput() {
			playbackInputContainer.toggleClass('playbackHidden');
			playbackDisplay.toggleClass('playbackHidden');
			seeking = !seeking;
		}

		function submitInput() {
			let date = [...$qa('#playbackInputDate input')].map(e => e.value.padStart(e.maxLength, '0')).join('/'),
				time = [...$qa('#playbackInputTime input')].map(e => e.value.padStart(e.maxLength, '0')).join(':'),
				m = moment(date + ' ' + time, 'yyyy/MM/DD HH:mm:ss');
			updateCurrentTime(m.unix());
			swapTimeDisplayAndInput();
		}

		function updatePlaybackSpeedDisplay() {
			let n = (Math.round(playbackSpeed*100)/100)+'x';
			playbackSpeedDisplay.html(n);
		}

		let playbackSpeedInput = $('#playbackSpeedInput').on('keyup', e => {
			let isNumber = /^[\d.]$/.test(e.key);
			if(/^[^\d\.]$/.test(e.key) && !e.ctrlKey) {
				e.preventDefault();
			}
			if(e.key == 'Escape') swapSpeedDisplayAndInput();
			if(e.key == 'Enter') {
				let newSpeed;
				try {
					newSpeed = parseFloat(playbackSpeedInput[0].value);
				} catch(e) { newSpeed = 1; }
				playbackSpeed = newSpeed;
				console.log('newSpeed', playbackSpeed);
				updatePlaybackSpeedDisplay();
				swapSpeedDisplayAndInput();
			}
		});

		let playbackSpeedDisplay = $('#playbackSpeedDisplay').click(e => {
			playbackSpeedInput[0].value = playbackSpeed; 
			swapSpeedDisplayAndInput();
			playbackSpeedInput.focus();
			playbackSpeedInput[0].setSelectionRange(0, playbackSpeedInput[0].value.length);
		});

		function swapSpeedDisplayAndInput() {
			playbackSpeedDisplay.toggleClass('playbackHidden');
			playbackSpeedInput.toggleClass('playbackHidden');
		}

		$('#playbackSkipBack').click(e => {
			updateCurrentTime(currentUnix - 5);
		});

		$('#playbackSkipAhead').click(e => {
			updateCurrentTime(currentUnix + 5);
		});

		let keydownElement;
		$('#playbackInputContainer input').on('keydown keyup', e => {
			if(e.type == 'keydown') {
				keydownElement = e.target;
				return;
			}
			if(e.target != keydownElement) {
				return;
			}
			let isNumber = /^\d$/.test(e.key);
			if(/^[^\d]$/.test(e.key) && !e.ctrlKey) {
				e.preventDefault();
			}
			if(isNumber && e.target.value.length == e.target.maxLength) {
				if(nextInput[e.target.id]) {
					let next = $id(nextInput[e.target.id]);
					next.focus();
					next.setSelectionRange(0, next.value.length);
				} else submitInput();
			}
			if(e.key == 'Enter') submitInput();
			if(e.key == 'Escape') swapTimeDisplayAndInput();
		});

		console.log('Playback Init complete');
	}
	doInit();
})();