// ==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== // @downloadURL https://update.greasyfork.icu/scripts/437444/4chan%20X%20Thread%20Playback.user.js // @updateURL https://update.greasyfork.icu/scripts/437444/4chan%20X%20Thread%20Playback.meta.js // ==/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', ''); 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', `
//
::
----
/
--
/
--
----
:
--
:
--
1x
`); } 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('
'); 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(); })();