4chan X Thread Playback

Plays back threads on 4chan X

As of 2021-12-22. See the latest version.

// ==UserScript==
// @name 4chan X Thread Playback
// @namespace VSJPlus
// @license GNU GPLv3
// @description Plays back threads on 4chan X
// @version 1.0.0
// @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
// ==/UserScript==
console.log('4chan X Playback');

(function() {

	var head = document.head;

	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 {
		    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';
		}
	`;

	head.appendChild(style);
})();

(() => {
	let isChanX;
	document.addEventListener(
		"DOMContentLoaded",
		function (event) {
			setTimeout(
				function () {
					if (
						document.body.classList.contains("ws") ||
						document.body.classList.contains("nws")
					) {
						isChanX = false;
						doInit();
					}
				},
				(1)
			);
		}
	);
	
	document.addEventListener(
		"4chanXInitFinished",
		function (event) {
			if (
				document.documentElement.classList.contains("fourchan-x") &&
				document.documentElement.classList.contains("sw-yotsuba")
			) {
				isChanX = true;
				doInit();
			}
		}
	);

	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) {
		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');
	}

	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');

		$(checkbox).click(() => {
			let checked = document.querySelector('#playbackToggleCheckbox').checked;
			$(document.documentElement).toggleClass('playbackEnabled');
			togglePlay(checked);
		});
		$('#playbackToggle').hover(e => $(e.target).addClass('focused').siblings().removeClass('focused'));
	}

	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 doInit() {
		console.log('Playback Init');
		if(!isChanX) return;
		let obs = new MutationObserver(e => {
			if(e[0].addedNodes.length) {
				setupPlaybackToggle();
			}
		});
		obs.observe(document.querySelector('#shortcut-menu'), {childList: true});
		setupPlaybackToggle();

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

		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');
	}
})();