// ==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 none // ==/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', `