4chan X Thread Playback

Plays back threads on 4chan X

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

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.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name 4chan X Thread Playback
// @namespace VSJPlus
// @license GNU GPLv3
// @description Plays back threads on 4chan X
// @version 1.0.3
// @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
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMjHxIGmVAAAAkUlEQVQ4T6WQAQ6AMAgD9yf/5Gv906QYSBGGRk1OXFuaxbEf2y/0Necc8uCl4FxRZa4WEWQ6HPDgIhNuUAUePRFSwIDeeV6gH02YsRx2QoELTQkvel6MIKhYlFTL4NUNoDEhzwUw78vAdJ6poFqGZtiZvbbAAuxVmVTAsyswP/0DngbOXMB+KFiBBSZ4KPjONk6FWw9BoS3PSwAAAABJRU5ErkJggg==
// ==/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;
			    position: relative;
			}

			#playbackSlider:hover {
			    background: #c4c4c4;
			}

			#playbackSlider #timestampPreview {
				position: absolute;
				width: 0;
				height: 0;
			}

			#playbackSlider #timestampPreview:after,
			#playbackSlider .ui-slider-handle.ui-state-active:after {
				top: unset !important;
				bottom: 100%;
				margin-top: unset !important;
				margin-bottom: 5px;
				opacity: 0;
				transition: opacity 100ms;
			}

			#playbackSlider .ui-slider-handle.ui-state-active:after {
				margin-bottom: 2px;
			}

			#playbackSlider:hover #timestampPreview:after,
			#playbackSlider .ui-slider-handle.ui-state-active:after {
				animation: none !important;
				opacity: 1;
			}

			#playbackSlider .ui-slider-handle:not(.ui-state-active):after,
			#playbackSlider.ui-state-active #timestampPreview {
				display: none !important;
			}


			#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: -1.5px;
			}

			#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 {
			    cursor: pointer;
			    position: relative;
			    top: -0.5px;
			    width: 18px;
			    height: 20px;
			    transition: transform 100ms;
			    transform-origin: 10px 12px;
			}

			@keyframes skipBack {
				0% { transform: rotate(0deg); }
				25% { transform: rotate(-35deg); }
				50% { transform: rotate(-20deg); }
				100% { transform: rotate(-25deg); }
			}

			#playbackSkipBack:active {
			    transform: rotate(-25deg);
			    animation: skipBack 50ms;
			}

			@keyframes skipAhead {
				0% { transform: rotate(0deg); }
				25% { transform: rotate(35deg); }
				50% { transform: rotate(20deg); }
				100% { transform: rotate(25deg); }
			}

			#playbackSkipAhead:active {
			    transform: rotate(25deg);
			    animation: skipAhead 50ms;
			}

			#playbackSkipBackContainer {
			    margin-left: 5px;
			}

			#playbackSkipBack .skipPath,
			#playbackSkipAhead .skipPath {
			    fill: #ccc;
			    transition: fill 100ms;
			}

			#playbackSkipBack:hover .skipPath,
			#playbackSkipAhead:hover .skipPath {
			    fill: #fff;
			}

			#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]:after {
			    opacity: 0;
			    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-family: arial, helvetica, sans-serif;
			    font-weight: normal;
			    font-size: 10px;
			    text-align: center;
			    z-index: 1;
			    pointer-events: none;
			    color: #ccc;
			}

			[tooltip]:hover:after {
			    animation: tooltipFade 800ms;
			    opacity: 1;
			}

			@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(interval) {
		maxUnix = isArchived() ? parseInt(Object.values(posts).map(p => p.timestamp).sort((a,b) => b-a)[0]):moment().unix();
		let increment = 1000/playbackSpeed;
		if(playing) currentUnix += interval/increment;
		currentUnix = Math.min(currentUnix, maxUnix);
		slider.slider('option', 'max', maxUnix);
		slider.slider('option', 'value', currentUnix);
	}

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

	function splitArray(array, limit) {
		let arrays = [];
		for(let i = 0; i < array.length; i += limit) {
			arrays.push(array.slice(i, i + limit));
		}
		return arrays;
	}

	const playbackHiddenPosts = [document.createElement('style')];
	let lastHiddenPosts;
	async function updatePostVisibility() {
		let selectors = Object.values(posts).filter(p => p.timestamp > currentUnix).map(p => p.selectors),
		newPosts = lastHiddenPosts != selectors.length;
		if(!newPosts) return;
		lastHiddenPosts = selectors.length;
		let scrollToBottom = false, docEl = document.documentElement;
		if(newPosts && autoScroll && (docEl.offsetHeight - (docEl.scrollTop + window.innerHeight)) < 100) {
			scrollToBottom = true;
		}
		let css = splitArray(selectors, 500)
				  .map(s => s.join(',')+'{display:none !important;}');
		while(playbackHiddenPosts.length < css.length) {
			let style = document.createElement('style');
			style.id = 'playbackHiddenPosts-'+playbackHiddenPosts.length;
			document.head.appendChild(style);
			playbackHiddenPosts.push(style);
		}
		for(let [k,v] of Object.entries(playbackHiddenPosts)) {
			playbackHiddenPosts[k].innerHTML = css[k] || '';
		}
		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');
			checkbox.setAttribute('disabled', '');
			await fourChanXInitFinished;
		}

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

		toggle.removeClass('loading');
		checkbox.removeAttribute('disabled');
	}

	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="playbackSkipBackContainer" tooltip="Back 5s"><svg id="playbackSkipBack" tooltip="Back 5s" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="19.090909090909093" height="24.204545454545457"><path class="skipPath" d="M9.55 7.23L9.55 7.23L9.55 4.83Q11.06 4.83 12.38 5.39Q13.69 5.96 14.69 6.96Q15.69 7.96 16.25 9.28Q16.82 10.60 16.82 12.10L16.82 12.10Q16.82 13.61 16.26 14.93Q15.70 16.24 14.69 17.24Q13.69 18.25 12.38 18.81Q11.06 19.38 9.55 19.38L9.55 19.38Q8.04 19.38 6.72 18.81Q5.40 18.25 4.40 17.24Q3.40 16.24 2.84 14.93Q2.27 13.61 2.27 12.10L2.27 12.10L4.67 12.11Q4.67 13.12 5.05 14.00Q5.42 14.88 6.09 15.55Q6.77 16.22 7.65 16.60Q8.54 16.97 9.55 16.97L9.55 16.97Q10.55 16.97 11.44 16.60Q12.33 16.22 13.00 15.55Q13.67 14.88 14.04 14.00Q14.42 13.12 14.42 12.11L14.42 12.11Q14.42 11.09 14.04 10.21Q13.66 9.33 12.99 8.65Q12.32 7.98 11.44 7.61Q10.55 7.23 9.55 7.23ZM9.87 1.32L9.87 10.95L4.96 6.14L9.87 1.32Z"/></svg></div>
				<div id="playbackPauseResume" tooltip="Pause"></div>
				<div id="playbackSkipAheadContainer" tooltip="Skip 5s"><svg id="playbackSkipAhead" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="19.090909090909093" height="24.204545454545457"><path class="skipPath" d="M9.55 19.38L9.55 19.38Q8.03 19.38 6.72 18.81Q5.40 18.25 4.40 17.24Q3.39 16.24 2.83 14.93Q2.27 13.61 2.27 12.10L2.27 12.10Q2.27 10.60 2.84 9.28Q3.40 7.96 4.40 6.96Q5.40 5.96 6.72 5.39Q8.03 4.83 9.55 4.83L9.55 4.83L9.55 7.23Q8.54 7.23 7.65 7.61Q6.77 7.98 6.10 8.65Q5.43 9.33 5.05 10.21Q4.67 11.09 4.67 12.10L4.67 12.10Q4.67 13.12 5.05 14.00Q5.42 14.88 6.09 15.55Q6.76 16.22 7.65 16.60Q8.53 16.97 9.55 16.97L9.55 16.97Q10.55 16.97 11.44 16.60Q12.32 16.22 12.99 15.55Q13.66 14.88 14.04 14.00Q14.42 13.11 14.42 12.10L14.42 12.10L16.82 12.10Q16.82 13.61 16.25 14.93Q15.69 16.24 14.69 17.24Q13.69 18.25 12.37 18.81Q11.05 19.38 9.55 19.38ZM14.13 6.14L9.23 10.95L9.23 1.32L14.13 6.14Z"/></svg></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);
	}


	const debounce = (() => {
		const debounceList = {};

		async function debounceSub(name, interval) {
			while(true) {
				if(debounceList[name]) {
					debounceList[name]();
					delete debounceList[name];
				} else return;
				await delay(interval);
			}
		}

		return (name, method, interval) => {
			const runSub = !debounceList[name];
			debounceList[name] = method;
			if(runSub) debounceSub(name, interval);
		}
	})();

	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[0].id = 'playbackHiddenPosts-0';
		document.head.appendChild(playbackHiddenPosts[0]);
		appendStyle();

		await fourChanXInitFinished;

		[...$qa('.postContainer')].forEach(pc => {
			posts[pc.dataset.fullID] = getPostData(pc.dataset.fullID);
		});

		document.addEventListener('ThreadUpdate', e => {
			if(e.detail && e.detail.newPosts && e.detail.newPosts.length) {
				for(let postID of e.detail.newPosts) {
					posts[postID] = getPostData(postID);
				}
				updatePostVisibility();
			}
		});

		setupPlaybackUI();

		startUnix = parseInt($('.opContainer .dateTime').attr('data-utc'));
		currentUnix = isArchived() ? parseInt(Object.values(posts).map(p => p.timestamp).sort((a,b) => b-a)[0]):moment().unix();
		maxUnix = currentUnix;

		console.log('start', startUnix, 'current', currentUnix, 'max', maxUnix);

		function renderPlayback(e, ui) {
			currentUnix = ui.value;
			debounce('renderPlaybackTimestamp', () => {
				let hoverTimestamp = moment.unix(currentUnix)
								 	 .format('yyyy/MM/DD HH:mm:ss');
				handle.attr('tooltip', hoverTimestamp);
			}, 16);
			debounce('renderPlayback', () => {
				updateDateTimeDisplay(currentUnix);
				updatePostVisibility();
			}, 250);
		}

		let updatePreviewSub;
		async function updatePreview() {
			while(true) {
				if(updatePreviewSub) {
					updatePreviewSub();
				} else return;
				await delay(16);
			}
		}


		slider = $('#playbackSlider').slider({
			min: startUnix,
			value: currentUnix,
			max: maxUnix,
			start: (e, ui) => {
				scrubbing = true;
				slider.addClass('ui-state-active');
			},
			stop: (e, ui) => {
				scrubbing = false;
				slider.removeClass('ui-state-active');
			},
			animate: 0,
			change: renderPlayback,
			slide: renderPlayback
		}).on('mousemove', e => {
			/*let runUpdatePreview = !updatePreviewSub;
			updatePreviewSub =*/ 
			debounce('updatePreview', () => {
				let rect = slider[0].getBoundingClientRect(),
					fraction = (e.clientX - rect.left)/rect.width,
					hoverTimestamp = Math.round(fraction*(maxUnix - startUnix)) + startUnix;
				hoverTimestamp = Math.max(Math.min(hoverTimestamp, maxUnix), startUnix);
				hoverTimestamp = moment.unix(hoverTimestamp)
								 .format('yyyy/MM/DD HH:mm:ss');
				preview.attr('tooltip', hoverTimestamp);

				let style = `left: ${e.clientX - rect.left}px`;
				preview[0].style = style;
				//updatePreviewSub = null;
			}, 16);
			//if(runUpdatePreview) updatePreview();
		});

		let handle = $('#playbackUI .ui-slider-handle');

		slider.append('<div id="timestampPreview"></div>');
		let preview = $('#timestampPreview');

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