// ==UserScript==
// @name 4chan X Thread Playback
// @namespace VSJPlus
// @license GNU GPLv3
// @description Plays back threads on 4chan X
// @version 1.0.2
// @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
// ==/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;
}
#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,
#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]: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';
}
#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() {
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) {
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');
await fourChanXInitFinished;
toggle.removeClass('loading');
}
$(checkbox).click(() => {
let checked = document.querySelector('#playbackToggleCheckbox').checked;
$(document.documentElement).toggleClass('playbackEnabled');
togglePlay(checked);
});
}
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="playbackSkipBack" tooltip="Back 5s"></div>
<div id="playbackPauseResume" tooltip="Pause"></div>
<div id="playbackSkipAhead" tooltip="Skip 5s"></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);
}
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.id = 'playbackHiddenPosts';
document.head.appendChild(playbackHiddenPosts);
appendStyle();
await fourChanXInitFinished;
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');
}
doInit();
})();