// ==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 // @downloadURL none // ==/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', ''); 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', `
//
::
----
/
--
/
--
----
:
--
:
--
1x
`); } 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'); } })();