4chan X Thread Playback

Plays back threads on 4chan X

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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