Greasy Fork is available in English.

4chan X Thread Playback

Plays back threads on 4chan X

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