// ==UserScript==
// @name Thread Media Viewer
// @description Comfy and efficient way how to navigate media files in a thread. Currently set up for 4chan and thebarchive.
// @version 1.1.0
// @namespace qimasho
// @match https://boards.4chan.org/*
// @match https://boards.4channel.org/*
// @match https://thebarchive.com/*
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/preact.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/hooks/dist/hooks.umd.js
// @grant GM_addStyle
// @license MIT
// ==/UserScript==
const WEBSITES = [
{
urlRegexp: /boards\.4chan(nel)?.org\/\w+\/thread\/\S+/i,
threadSelector: '.board .thread',
postSelector: '.post',
serialize: (post) => {
const titleAnchor = post.querySelector('.fileText a');
const url = post.querySelector('a.fileThumb')?.href;
return {
meta: post.querySelector('.fileText')?.textContent.match(/\(([^\(\)]+ *, *\d+x\d+)\)/)?.[1],
url,
thumbnailUrl: post.querySelector('a.fileThumb img')?.src,
title: titleAnchor?.title || titleAnchor?.textContent || url?.match(/\/([^\/]+)$/)?.[1],
replies: post.querySelectorAll('.postInfo .backlink a.quotelink')?.length ?? 0,
};
},
},
{
urlRegexp: /thebarchive\.com\/b\/thread\/\S+/i,
threadSelector: '.thread .posts',
postSelector: '.post',
serialize: (post) => {
const titleElement = post.querySelector('.post_file_filename');
const url = post.querySelector('a.thread_image_link')?.href;
return {
meta: post.querySelector('.post_file_metadata')?.textContent,
url,
thumbnailUrl: post.querySelector('img.post_image')?.src,
title: titleElement?.title || titleElement?.textContent || url?.match(/\/([^\/]+)$/)?.[1],
replies: post.querySelectorAll('.backlink_list a.backlink')?.length ?? 0,
};
},
},
];
// @ts-ignore
const {h, render} = preact;
// @ts-ignore
const {useState, useEffect, useRef, useMemo, useCallback} = preactHooks;
const {round, min, max, hypot, abs, floor} = Math;
const INTERACTIVE = {INPUT: true, TEXTAREA: true, SELECT: true};
const cn = (name) => `_tm_media_browser_` + name;
const log = (...args) => console.log('MediaBrowser:', ...args);
const storage = syncedStorage(cn('storage'), {volume: 0.5});
const CONFIG = {
adjustVolumeBy: 0.125,
seekBy: 5,
gestureDistance: 30,
totalTime: true,
};
const website = WEBSITES.find((config) => config.urlRegexp.exec(location.href));
if (website) {
log('url matched', website.urlRegexp);
const threadElement = document.querySelector(website.threadSelector);
const watcher = mediaWatcher(website);
const container = Object.assign(document.createElement('div'), {className: cn('container')});
document.body.appendChild(container);
render(h(App, {watcher, threadElement}), container);
}
function App({watcher, threadElement}) {
const [isOpen, setIsOpen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const media = useThreadMedia(watcher);
const [activeIndex, setActiveIndex] = useState(null);
const toggleHelp = useCallback(() => setShowHelp((value) => !value), []);
const closeHelp = useCallback(() => setShowHelp(false), []);
// Shortcuts
useKey('`', () => {
setIsOpen((isOpen) => {
setShowHelp((showHelp) => !isOpen && showHelp);
return !isOpen;
});
});
useKey('~', () => setShowHelp((value) => !value));
useKey('F', () => setActiveIndex(null));
// Intercept clicks to media files and open them in MediaBrowser
useEffect(() => {
function handleClick(event) {
const url = event.target?.closest('a')?.href;
if (url && watcher.mediaByURL.has(url)) {
const mediaIndex = watcher.media.findIndex((media) => media.url === url);
if (mediaIndex != null) {
event.stopPropagation();
event.preventDefault();
setActiveIndex(mediaIndex);
if (event.shiftKey) setIsOpen(true);
}
}
}
threadElement.addEventListener('click', handleClick);
return () => {
threadElement.removeEventListener('click', handleClick);
};
}, []);
// Mouse gestures
useEffect(() => {
let gestureStart = null;
function handleMouseDown({which, x, y}) {
if (which === 3) gestureStart = {x, y};
}
function handleMouseUp({which, x, y}) {
if (which !== 3 || !gestureStart) return;
const dragDistance = hypot(x - gestureStart.x, y - gestureStart.y);
if (dragDistance < CONFIG.gestureDistance) return;
let gesture;
if (abs(gestureStart.x - x) < dragDistance / 2) {
gesture = gestureStart.y < y ? 'down' : 'up';
}
switch (gesture) {
case 'down':
setActiveIndex(null);
break;
case 'up':
setIsOpen((isOpen) => !isOpen);
break;
}
// Clear and prevent context menu
gestureStart = null;
if (gesture) {
const preventContext = (event) => event.preventDefault();
window.addEventListener('contextmenu', preventContext, {once: true});
// Unbind after a couple milliseconds to not clash with other
// tools that prevent context, such as gesture extensions.
setTimeout(() => window.removeEventListener('contextmenu', preventContext), 10);
}
}
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
return h('div', {class: `${cn('MediaBrowser')} ${isOpen ? cn('-is-open') : ''}`}, [
isOpen &&
h(Gallery, {
key: 'list',
media,
activeIndex,
onActivation: setActiveIndex,
onToggleHelp: toggleHelp,
}),
showHelp && h(Help, {onClose: closeHelp}),
activeIndex != null && media[activeIndex] && h(MediaView, {item: media[activeIndex]}),
]);
}
function Help({onClose}) {
return h('div', {class: cn('Help')}, [
h('button', {class: cn('close'), onClick: onClose}, '×'),
h('h2', {}, 'Mouse controls'),
h('ul', {}, [
h('li', {}, ['Right button gesture ', h('code', {}, '↑'), ' to toggle media list.']),
h('li', {}, ['Right button gesture ', h('code', {}, '↓'), ' to close media view.']),
h('li', {}, [h('code', {}, 'click'), ' on thumbnail (thread or list) to open media viewer.']),
h('li', {}, [h('code', {}, 'shift+click'), ' on thumbnail (thread) to open both media view and list.']),
h('li', {}, [h('code', {}, 'mouse wheel'), ' on video to change audio volume.']),
h('li', {}, [h('code', {}, 'mouse wheel'), ' on timeline to seek video.']),
h('li', {}, [h('code', {}, 'mouse down'), ' on image for 1:1 zoom, move to pan.']),
]),
h('h2', {}, 'Shortcuts'),
h('ul', {}, [
h('li', {}, [h('code', {}, '`'), ' Toggle media list.']),
h('li', {}, [h('code', {}, '~'), ' Toggle help.']),
h('li', {}, [h('code', {}, 'w/a/s/d'), ' Navigate without activating.']),
h('li', {}, [h('code', {}, 'W/A/S/D'), ' Navigate with activating.']),
h('li', {}, [h('code', {}, 'f'), ' View selected item (toggle).']),
h('li', {}, [h('code', {}, 'F'), ' Close current media view.']),
h('li', {}, [h('code', {}, 'home/end'), ' Navigate to top/bottom.']),
h('li', {}, [h('code', {}, 'pageUp/pageDown'), ' Navigate one screen up/down.']),
h('li', {}, [
h('code', {}, 'tab (hold)'),
' Full page media view (also, videos that cover less than half of available space receive 2x zoom).',
]),
h('li', {}, [h('code', {}, 'q/e'), ' Seek video backward/forward.']),
h('li', {}, [h('code', {}, '0-9'), ' Seek video to a tenth percentile (1=10%).']),
h('li', {}, [h('code', {}, 'space'), ' Pause/Play.']),
h('li', {}, [h('code', {}, 'shift+space (hold)'), ' Fast forward (x5).']),
h('li', {}, [h('code', {}, 'Q/E'), ' Decrease/increase audio volume.']),
]),
h('h2', {}, 'FAQ'),
h('dl', {}, [
h('dt', {}, "Why does the page scroll when I'm navigating items?"),
h('dd', {}, 'It scrolls to place the associated post right below the media list box.'),
h('dt', {}, 'What are the small squares at the bottom of thumbnails?'),
h('dd', {}, 'Visualization of the number of replies the post has.'),
]),
]);
}
function Gallery({media, activeIndex, onActivation, onToggleHelp}) {
const mainContainer = useRef(null);
const listContainer = useRef(null);
let [selectedIndex, setSelectedIndex] = useState(activeIndex);
let [windowWidth] = useWindowDimensions();
let itemsPerRow = useItemsPerRow(listContainer, [windowWidth, media.length]);
// If there is no selected item, select the item closest to the center of the screen
if (selectedIndex == null) {
const centerOffset = window.innerHeight / 2;
let lastProximity = Infinity;
for (let i = 0; i < media.length; i++) {
const rect = media[i].container.getBoundingClientRect();
let proximity = Math.abs(centerOffset - rect.top);
if (rect.top > centerOffset) {
selectedIndex = lastProximity < proximity ? i - 1 : i;
break;
}
lastProximity = proximity;
}
if (selectedIndex == null && media.length > 0) selectedIndex = media.length - 1;
if (selectedIndex >= 0) setSelectedIndex(selectedIndex);
}
function scrollToItem(index, behavior = 'smooth') {
if (listContainer.current?.children[index]) {
listContainer.current?.children[index]?.scrollIntoView({block: 'center', behavior});
}
}
function selectAndScrollTo(setter) {
setSelectedIndex((index) => {
const nextIndex = typeof setter === 'function' ? setter(index) : setter;
scrollToItem(nextIndex);
return nextIndex;
});
}
// If activeIndex changes externally, make sure selectedIndex matches it
useEffect(() => {
if (activeIndex != null && activeIndex != selectedIndex) selectAndScrollTo(activeIndex);
}, [activeIndex]);
// Scroll to selected item when list opens
useEffect(() => selectedIndex != null && scrollToItem(selectedIndex, 'auto'), []);
// Scroll to the associated post
useEffect(() => {
if (media?.[selectedIndex]?.container && mainContainer.current) {
let offset = getBoundingDocumentRect(mainContainer.current).height;
scrollToElement(media[selectedIndex].container, offset);
}
}, [selectedIndex]);
// Keyboard navigation
useKey('w', () => selectAndScrollTo((i) => max(i - itemsPerRow, 0)), [itemsPerRow]);
useKey(
's',
() => {
selectAndScrollTo((i) => {
// Scroll to the bottom when S is pressed when already at the end of the media list.
// This facilitates clearing new posts notifications.
if (i == media.length - 1) {
document.scrollingElement.scrollTo({
top: document.scrollingElement.scrollHeight,
behavior: 'smooth',
});
}
return min(i + itemsPerRow, media.length - 1);
});
},
[itemsPerRow, media.length]
);
useKey('Home', () => selectAndScrollTo(0), []);
useKey('End', () => selectAndScrollTo(media.length - 1), [media.length]);
useKey('PageUp', () => selectAndScrollTo((i) => max(i - itemsPerRow * 3, 0)), [itemsPerRow]);
useKey('PageDown', () => selectAndScrollTo((i) => min(i + itemsPerRow * 3, media.length)), [
itemsPerRow,
media.length,
]);
useKey('a', () => selectAndScrollTo((i) => max(i - 1, 0)));
useKey('d', () => selectAndScrollTo((i) => min(i + 1, media.length - 1)), [media.length]);
useKey(
'W',
() => {
const index = max(selectedIndex - itemsPerRow, 0);
selectAndScrollTo(index);
onActivation(index);
},
[selectedIndex, itemsPerRow]
);
useKey(
'S',
() => {
// Scroll to the bottom when S is pressed when already at the end of the media list.
// This facilitates clearing new posts notifications.
if (selectedIndex == media.length - 1) {
document.scrollingElement.scrollTo({
top: document.scrollingElement.scrollHeight,
behavior: 'smooth',
});
}
const index = min(selectedIndex + itemsPerRow, media.length - 1);
selectAndScrollTo(index);
onActivation(index);
},
[selectedIndex, itemsPerRow, media.length]
);
useKey(
'A',
() => {
const prevIndex = max(selectedIndex - 1, 0);
selectAndScrollTo(prevIndex);
onActivation(prevIndex);
},
[selectedIndex]
);
useKey(
'D',
() => {
const nextIndex = min(selectedIndex + 1, media.length - 1);
selectAndScrollTo(nextIndex);
onActivation(nextIndex);
},
[selectedIndex, media.length]
);
useKey(
'f',
() => {
onActivation((activeIndex) => (selectedIndex === activeIndex ? null : selectedIndex));
},
[selectedIndex]
);
return h('div', {class: cn('Gallery'), ref: mainContainer}, [
h(
'div',
{class: cn('list'), ref: listContainer},
media.map((item, index) => {
return h(
'a',
{
key: item.url,
href: item.url,
class: `${selectedIndex === index ? cn('selected') : ''} ${
activeIndex === index ? cn('active') : ''
}`,
onClick: (event) => {
event.preventDefault();
setSelectedIndex(index);
onActivation(index);
},
},
[
h('img', {src: item.thumbnailUrl}),
item.meta && h('span', {class: cn('meta')}, item.meta),
(item.isVideo || item.isGif) && h('div', {class: cn('video-type')}, null, item.extension),
item?.replies > 0 && h('div', {class: cn('replies')}, null, Array(item.replies).fill(h('div'))),
]
);
})
),
h('div', {class: cn('meta')}, [
h('div', {class: cn('actions')}, [h('button', {onClick: onToggleHelp}, '? help')]),
h('div', {class: cn('position')}, [
h('span', {class: cn('current')}, selectedIndex + 1),
h('span', {class: cn('separator')}, '/'),
h('span', {class: cn('total')}, media.length),
]),
]),
]);
}
function MediaView({item}) {
const containerElement = useRef(null);
const mediaElement = useRef(null);
const [error, setError] = useState(null);
const [displaySpinner, setDisplaySpinner] = useState(true);
// Zoom in on Tab down
useKey(
'Tab',
(event) => {
event.preventDefault();
if (event.repeat) return;
containerElement.current.classList.add(cn('expanded'));
// double the size of tiny videos (fill less than half of available space)
const video = mediaElement.current;
if (
video?.nodeName === 'VIDEO' &&
video.videoWidth < window.innerWidth / 2 &&
video.videoHeight < window.innerHeight / 2
) {
const windowAspectRatio = window.innerWidth / window.innerHeight;
const videoAspectRatio = video.videoWidth / video.videoHeight;
let newHeight, newWidth;
if (windowAspectRatio > videoAspectRatio) {
newHeight = min(video.videoHeight * 2, round(window.innerHeight * 0.8));
newWidth = round(video.videoWidth * (newHeight / video.videoHeight));
} else {
newWidth = min(video.videoWidth * 2, round(window.innerWidth * 0.8));
newHeight = round(video.videoHeight * (newWidth / video.videoWidth));
}
video.style = `width:${newWidth}px;height:${newHeight}px`;
}
},
[]
);
// Zoom out (restore) on Tab up
useKeyUp(
'Tab',
(event) => {
containerElement.current.classList.remove(cn('expanded'));
// clean up size doubling of tiny videos
mediaElement.current.style = '';
},
[]
);
// Initialize new item
useEffect(() => {
setDisplaySpinner(true);
setError(null);
}, [item]);
// 100% zoom + dragging on mousedown for images
const handleMouseDown = useCallback(
(event) => {
if (event.which !== 1 || item.isVideo) return;
event.preventDefault();
event.stopPropagation();
const zoomMargin = 10;
const image = mediaElement.current;
const previewRect = image.getBoundingClientRect();
const zoomFactor = image.naturalWidth / previewRect.width;
const cursorAnchorX = previewRect.left + previewRect.width / 2;
const cursorAnchorY = previewRect.top + previewRect.height / 2;
containerElement.current.classList.add(cn('expanded'));
const availableWidth = containerElement.current.clientWidth;
const availableHeight = containerElement.current.clientHeight;
const dragWidth = max((previewRect.width - availableWidth / zoomFactor) / 2, 0);
const dragHeight = max((previewRect.height - availableHeight / zoomFactor) / 2, 0);
const translateWidth = max((image.naturalWidth - availableWidth) / 2, 0);
const translateHeight = max((image.naturalHeight - availableHeight) / 2, 0);
Object.assign(image.style, {
maxWidth: 'none',
maxHeight: 'none',
width: 'auto',
height: 'auto',
position: 'fixed',
top: '50%',
left: '50%',
});
handleMouseMove(event);
function handleMouseMove(event) {
const dragFactorX = dragWidth > 0 ? -((event.clientX - cursorAnchorX) / dragWidth) : 0;
const dragFactorY = dragHeight > 0 ? -((event.clientY - cursorAnchorY) / dragHeight) : 0;
const left = round(
min(max(dragFactorX * translateWidth, -translateWidth - zoomMargin), translateWidth + zoomMargin)
);
const top = round(
min(max(dragFactorY * translateHeight, -translateHeight - zoomMargin), translateHeight + zoomMargin)
);
image.style.transform = `translate(-50%, -50%) translate(${left}px, ${top}px)`;
}
function handleMouseUp() {
containerElement.current.classList.remove(cn('expanded'));
image.style = '';
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('mousemove', handleMouseMove);
}
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('mousemove', handleMouseMove);
},
[item]
);
return h('div', {class: cn('MediaView'), ref: containerElement, onMouseDown: handleMouseDown}, [
displaySpinner && h('div', {class: cn('spinner-wrapper')}, h(Spinner)),
error
? h(MediaViewError, {message: error.message || 'Error loading media'})
: h(item.isVideo ? MediaViewVideo : MediaViewImage, {
key: item.url,
url: item.url,
mediaRef: mediaElement,
onReady: () => setDisplaySpinner(false),
onError: (error) => {
setDisplaySpinner(false);
setError(error);
},
}),
]);
}
function MediaViewImage({url, mediaRef, onReady, onError}) {
const imageRef = mediaRef || useRef(null);
useEffect(() => {
const intervalId = setInterval(() => {
if (imageRef.current?.naturalWidth > 0) {
onReady();
clearInterval(intervalId);
}
}, 50);
return () => clearInterval(intervalId);
}, [url]);
return h('img', {class: cn('MediaViewImage'), ref: imageRef, onError, src: url});
}
function MediaViewVideo({url, mediaRef, onReady, onError}) {
const [volume, setVolume] = useState(storage.volume);
const containerRef = useRef(null);
const videoRef = mediaRef || useRef(null);
const volumeRef = useRef(null);
const hasAudio = videoRef.current?.audioTracks?.length > 0 || videoRef.current?.mozHasAudio;
function playPause() {
if (videoRef.current.paused || videoRef.current.ended) videoRef.current.play();
else videoRef.current.pause();
}
useEffect(() => (storage.volume = volume), [volume]);
// Video controls and settings synchronization
useEffect(() => {
const container = containerRef.current;
const video = videoRef.current;
const volume = volumeRef.current;
function handleStorageSync(prop, value) {
if (prop === 'volume') setVolume(value);
}
function handleClick(event) {
if (event.target !== container && event.target !== video) return;
playPause();
// Fullscreen toggle on double click
if (event.detail === 2) {
if (!document.fullscreenElement) {
container.requestFullscreen().catch((error) => {
console.log(`Error when enabling full-screen mode: ${error.message} (${error.name})`);
});
} else {
document.exitFullscreen();
}
}
}
function handleVolumeMouseDown(event) {
if (event.which !== 1) return;
const pointerTimelineSeek = throttle((mouseEvent) => {
let {top, height} = getBoundingDocumentRect(volume);
let pos = min(max(1 - (mouseEvent.pageY - top) / height, 0), 1);
setVolume(round(pos / CONFIG.adjustVolumeBy) * CONFIG.adjustVolumeBy);
}, 100);
function unbind() {
window.removeEventListener('mousemove', pointerTimelineSeek);
window.removeEventListener('mouseup', unbind);
}
window.addEventListener('mousemove', pointerTimelineSeek);
window.addEventListener('mouseup', unbind);
pointerTimelineSeek(event);
}
function handleContainerWheel(event) {
event.preventDefault();
event.stopPropagation();
setVolume((volume) => min(max(volume + CONFIG.adjustVolumeBy * (event.deltaY > 0 ? -1 : 1), 0), 1));
}
const intervalId = setInterval(() => {
if (video.videoHeight > 0) {
onReady();
clearInterval(intervalId);
}
}, 50);
function handleError(error) {
onError(error);
clearInterval(intervalId);
}
storage.syncListeners.add(handleStorageSync);
video.addEventListener('error', handleError);
container.addEventListener('click', handleClick);
container.addEventListener('wheel', handleContainerWheel);
volume?.addEventListener('mousedown', handleVolumeMouseDown);
video.play();
return () => {
clearInterval(intervalId);
storage.syncListeners.delete(handleStorageSync);
video.removeEventListener('error', handleError);
container.removeEventListener('click', handleClick);
container.removeEventListener('wheel', handleContainerWheel);
volume?.removeEventListener('mousedown', handleVolumeMouseDown);
};
}, [url]);
const flashVolume = useMemo(() => {
let timeoutId;
return () => {
if (timeoutId) clearTimeout(timeoutId);
volumeRef.current.style.opacity = 1;
timeoutId = setTimeout(() => {
volumeRef.current.style = '';
}, 400);
};
}, [volumeRef.current]);
useKey(' ', playPause);
useKey('shift+ ', (event) => {
if (videoRef.current && !event.repeat) videoRef.current.playbackRate = 5;
});
useKeyUp('shift+ ', () => {
if (videoRef.current) videoRef.current.playbackRate = 1;
});
useKey('Q', () => {
setVolume((volume) => max(volume - CONFIG.adjustVolumeBy, 0));
flashVolume();
});
useKey('E', () => {
setVolume((volume) => min(volume + CONFIG.adjustVolumeBy, 1));
flashVolume();
});
useKey('q', () => {
const video = videoRef.current;
video.currentTime = max(video.currentTime - CONFIG.seekBy, 0);
});
useKey('e', () => {
const video = videoRef.current;
video.currentTime = min(video.currentTime + CONFIG.seekBy, video.duration);
});
// Time navigation by numbers, 1=10%, 5=50%, ... 0=0%
for (let key of [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]) {
useKey(String(key), () => {
if (videoRef.current?.duration > 0) videoRef.current.currentTime = videoRef.current.duration * (key / 10);
});
}
return h('div', {class: cn('MediaViewVideo'), ref: containerRef}, [
h('video', {
src: url,
ref: videoRef,
autoplay: false,
preload: false,
controls: false,
loop: true,
volume: volume,
}),
h(VideoTimeline, {videoRef}),
h(
'div',
{
class: cn('volume'),
ref: volumeRef,
style: hasAudio ? 'display: hidden' : '',
},
h('div', {
class: cn('bar'),
style: `height: ${Number(volume) * 100}%`,
})
),
]);
}
function VideoTimeline({videoRef}) {
const [state, setState] = useState({progress: 0, elapsed: 0, remaining: 0, duration: 0});
const [bufferedRanges, setBufferedRanges] = useState([]);
const timelineRef = useRef(null);
// Video controls and settings synchronization
useEffect(() => {
const video = videoRef.current;
const timeline = timelineRef.current;
function handleTimeupdate() {
setState({
progress: video.currentTime / video.duration,
elapsed: video.currentTime,
remaining: video.duration - video.currentTime,
duration: video.duration,
});
}
function handleMouseDown(event) {
if (event.which !== 1) return;
const pointerTimelineSeek = throttle((mouseEvent) => {
let {left, width} = getBoundingDocumentRect(timeline);
let pos = min(max((mouseEvent.pageX - left) / width, 0), 1);
video.currentTime = pos * video.duration;
}, 100);
function unbind() {
window.removeEventListener('mousemove', pointerTimelineSeek);
window.removeEventListener('mouseup', unbind);
}
window.addEventListener('mousemove', pointerTimelineSeek);
window.addEventListener('mouseup', unbind);
pointerTimelineSeek(event);
}
function handleWheel(event) {
event.preventDefault();
event.stopPropagation();
video.currentTime = video.currentTime + 5 * (event.deltaY > 0 ? 1 : -1);
}
function handleProgress() {
const buffer = video.buffered;
const duration = video.duration;
const ranges = [];
for (let i = 0; i < buffer.length; i++) {
ranges.push({
start: buffer.start(i) / duration,
end: buffer.end(i) / duration,
});
}
setBufferedRanges(ranges);
}
// `progress` event doesn't fire properly for some reason. Majority of videos get a single `progress`
// event when `video.buffered` ranges are not yet initialized (useless), than another event when
// buffered ranges are at like 3%, and than another event when ranges didn't change from before,
// and that's it... no event for 100% done loading, nothing. I've tried debugging this for hours
// with no success. The only solution is to just interval it until we detect the video is fully loaded.
const progressInterval = setInterval(() => {
handleProgress();
// clear interval when done loading - this is a naive check that doesn't account for missing middle parts
if (video.buffered.length > 0 && video.buffered.end(video.buffered.length - 1) == video.duration) {
clearInterval(progressInterval);
}
}, 500);
// video.addEventListener('progress', handleProgress);
video.addEventListener('timeupdate', handleTimeupdate);
timeline.addEventListener('wheel', handleWheel);
timeline.addEventListener('mousedown', handleMouseDown);
return () => {
// video.removeEventListener('progress', handleProgress);
video.removeEventListener('timeupdate', handleTimeupdate);
timeline.removeEventListener('wheel', handleWheel);
timeline.removeEventListener('mousedown', handleMouseDown);
};
}, []);
const elapsedTime = formatSeconds(state.elapsed);
const totalTime = formatSeconds(CONFIG.totalTime ? state.duration : state.remaining);
return h('div', {class: cn('timeline'), ref: timelineRef}, [
...bufferedRanges.map(({start, end}) =>
h('div', {
class: cn('buffered-range'),
style: {
left: `${start * 100}%`,
right: `${100 - end * 100}%`,
},
})
),
h('div', {class: cn('elapsed')}, elapsedTime),
h('div', {class: cn('total')}, totalTime),
h('div', {class: cn('progress'), style: `width: ${state.progress * 100}%`}, [
h('div', {class: cn('elapsed')}, elapsedTime),
h('div', {class: cn('total')}, totalTime),
]),
]);
}
function MediaViewError({message = 'Error'}) {
return h('div', {class: cn('MediaViewError')}, message);
}
function Spinner() {
return h('div', {class: cn('Spinner')});
}
function useItemsPerRow(ref, dependencies) {
let [itemsPerRow, setItemsPerRow] = useState(1);
useEffect(() => {
if (!ref.current?.children[0]) return;
setItemsPerRow(floor(ref.current.clientWidth / ref.current.children[0].offsetWidth));
}, [...dependencies, ref.current]);
return itemsPerRow;
}
function useWindowDimensions() {
let [dimensions, setDimensions] = useState([window.innerWidth, window.innerHeight]);
useEffect(() => {
let timeoutID;
let handleResize = () => {
if (timeoutID) clearTimeout(timeoutID);
timeoutID = setTimeout(() => setDimensions([window.innerWidth, window.innerHeight], 100));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return dimensions;
}
function useThreadMedia(watcher) {
let [media, setMedia] = useState(watcher.media);
useEffect(() => {
let updateMedia = (_, media) => setMedia(media);
watcher.onChange.add(updateMedia);
return () => watcher.onChange.delete(updateMedia);
}, [watcher]);
return media;
}
let handlersByShortcut = {
keydown: new Map(),
keyup: new Map(),
};
function triggerHandlers(event) {
// @ts-ignore
if (INTERACTIVE[event.target.nodeName]) return;
let key = String(event.key);
let shortcutName = '';
if (event.altKey) shortcutName += 'alt';
if (event.ctrlKey) shortcutName += shortcutName.length > 0 ? '+ctrl' : 'ctrl';
// This condition tries to identify keys that have no alternative input when pressing shift
if (event.shiftKey && (key === ' ' || key.length > 1)) shortcutName += shortcutName.length > 0 ? '+shift' : 'shift';
shortcutName += (shortcutName.length > 0 ? '+' : '') + key;
let handlers = handlersByShortcut[event.type].get(shortcutName);
if (handlers?.length > 0) {
event.preventDefault();
handlers[handlers.length - 1](event);
}
}
window.addEventListener('keydown', triggerHandlers);
window.addEventListener('keyup', triggerHandlers);
function _useKey(event, shortcut, handler, dependencies = []) {
useEffect(() => {
if (!shortcut) return;
let handlers = handlersByShortcut[event].get(shortcut);
if (!handlers) {
handlers = [];
handlersByShortcut[event].set(shortcut, handlers);
}
handlers.push(handler);
return () => {
let indexOfHandler = handlers.indexOf(handler);
if (indexOfHandler >= 0) handlers.splice(indexOfHandler, 1);
};
}, [shortcut, ...dependencies]);
}
function useKey(shortcut, handler, dependencies) {
_useKey('keydown', shortcut, handler, dependencies);
}
function useKeyUp(shortcut, handler, dependencies) {
_useKey('keyup', shortcut, handler, dependencies);
}
function mediaWatcher(website) {
const watcher = {
website: website,
media: [],
mediaByURL: new Map(),
onChange: new Set(),
threadContainer: document.querySelector(website.threadSelector),
};
watcher.serialize = () => {
let media = [...watcher.media];
let addedMedia = [];
let hasNewMedia = false;
let hasChanges = false;
for (let element of watcher.threadContainer.querySelectorAll(watcher.website.postSelector)) {
let data = watcher.website.serialize(element);
// Ignore items that failed to serialize necessary data
if (data.url == null || data.thumbnailUrl == null) continue;
data.extension = String(data.url.match(/\.([^.]+)$/)?.[1] || '').toLowerCase();
data.isVideo = !!data.extension.match(/webm|mp4/);
data.isGif = data.extension === 'gif';
data.meta = data?.meta ? data?.meta.replace('x', '×') : null;
let item = {...data, container: element};
let existingItem = watcher.mediaByURL.get(data.url);
if (existingItem) {
// Update existing items (stuff like reply counts)
if (JSON.stringify(existingItem) !== JSON.stringify(item)) {
Object.assign(existingItem, item);
hasChanges = true;
}
continue;
}
media.push(item);
watcher.mediaByURL.set(data.url, item);
addedMedia.push(item);
hasNewMedia = true;
}
watcher.media = media;
if (hasNewMedia || hasChanges) {
for (let handler of watcher.onChange.values()) handler(addedMedia, watcher.media);
}
};
if (watcher.threadContainer) {
watcher.serialize();
watcher.observer = new MutationObserver(watcher.serialize);
watcher.observer.observe(watcher.threadContainer, {childList: true, subtree: true});
} else {
log('no thread container found');
}
return watcher;
}
/**
* localStorage wrapper that saves into a namespaced key as json, and provides
* tab synchronization listeners.
* Usage:
* ```
* let storage = syncedStorage('localStorageKey'); // pre-loads
* storage.foo; // retrieve
* storage.foo = 5; // saves to localStorage automatically
* storage.syncListeners.add((prop, newValue, oldValue) => {}); // when other tab changes storage this is called
* storage.syncListeners.delete(fn); // remove listener
* ```
*/
function syncedStorage(localStorageKey, defaults = {}, {syncInterval = 1000} = {}) {
let control = {
syncListeners: new Set(),
savingPromise: null,
};
let storage = {...defaults, ...load()};
let proxy = new Proxy(storage, {
get(storage, prop) {
if (control.hasOwnProperty(prop)) return control[prop];
return storage[prop];
},
set(storage, prop, value) {
storage[prop] = value;
save();
return true;
},
});
setInterval(() => {
let newData = load();
for (let key in newData) {
if (newData[key] !== storage[key]) {
let oldValue = storage[key];
storage[key] = newData[key];
for (let callback of control.syncListeners.values()) {
callback(key, newData[key], oldValue);
}
}
}
}, syncInterval);
function load() {
let json = localStorage.getItem(localStorageKey);
let data;
try {
data = JSON.parse(json);
} catch (error) {
data = {};
}
return data;
}
function save() {
if (control.savingPromise) return control.savingPromise;
control.savingPromise = new Promise((resolve) =>
setTimeout(() => {
localStorage.setItem(localStorageKey, JSON.stringify(storage));
control.savingPromise = null;
resolve();
}, 10)
);
return control.savingPromise;
}
return proxy;
}
function getBoundingDocumentRect(el) {
if (!el) return;
const {width, height, top, left, bottom, right} = el.getBoundingClientRect();
return {
width,
height,
top: window.scrollY + top,
left: window.scrollX + left,
bottom: window.scrollY + bottom,
right: window.scrollX + right,
};
}
function scrollToElement(el, offset = 0, smooth = true) {
document.scrollingElement.scrollTo({
top: getBoundingDocumentRect(el).top - offset,
behavior: smooth ? 'smooth' : 'auto',
});
}
function formatSeconds(seconds) {
let minutes = floor(seconds / 60);
let leftover = round(seconds - minutes * 60);
// @ts-ignore
return `${String(minutes).padStart(2, 0)}:${String(leftover).padStart(2, 0)}`;
}
function throttle(func, wait) {
var ctx, args, rtn, timeoutID; // caching
var last = 0;
return function throttled() {
ctx = this;
args = arguments;
var delta = Date.now() - last;
if (!timeoutID)
if (delta >= wait) call();
else timeoutID = setTimeout(call, wait - delta);
return rtn;
};
function call() {
timeoutID = 0;
last = +new Date();
rtn = func.apply(ctx, args);
ctx = null;
args = null;
}
}
// @ts-ignore
GM_addStyle(`
/* 4chan tweaks */
/*
body.is_thread *, body.is_catalog *, body.is_arclist * {font-size: inherit !important;}
body.is_thread, body.is_catalog, body.is_arclist {font-size: 16px;}
.post.reply {display: block; max-width: 40%;}
.post.reply .post.reply {max-width: none;}
.sideArrows {display: none;}
.prettyprint {display: block;}
*/
/* Media Browser */
.${cn('MediaBrowser')},
.${cn('MediaBrowser')} *,
.${cn('MediaBrowser')} *:before,
.${cn('MediaBrowser')} *:after {box-sizing: border-box;}
.${cn('MediaBrowser')} {
--media-list-width: 640px;
--media-list-height: 50vh;
--grid-spacing: 5px;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 0;
font-size: 16px;
}
.${cn('Help')} {
position: fixed;
bottom: 0;
left: 0;
width: var(--media-list-width);
height: var(--media-list-height);
padding: 1em 1.5em;
background: #111;
color: #aaa;
overflow: auto;
scrollbar-width: thin;
}
.${cn('Help')} .${cn('close')} {
position: sticky;
top: 0; left: 10px;
float: right;
margin: 0 -.5em 0 0;
padding: 0 .3em;
background: transparent;
border: 0;
font-size: 2em !important;
color: #eee;
}
.${cn('Help')} h2 { font-size: 1.2em !important; font-weight: bold; }
.${cn('Help')} ul { list-style: none; padding-left: 1em; }
.${cn('Help')} li { padding: .1em 0; }
.${cn('Help')} code {
padding: 0 .2em;
font-weight: bold;
color: #222;
border-radius: 2px;
background: #eee;
}
.${cn('Help')} dt { font-weight: bold; }
.${cn('Help')} dd { margin: .1em 0 .8em; color: #888; }
.${cn('Gallery')} {
--item-width: 200px;
--item-height: 160px;
--item-border-size: 2px;
--item-meta-height: 18px;
--list-meta-height: 22px;
--active-color: #fff;
position: absolute;
top: 0;
left: 0;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr var(--list-meta-height);
width: var(--media-list-width);
height: var(--media-list-height);
background: #111;
box-shadow: 0px 0px 0 3px #0003;
}
.${cn('Gallery')} > .${cn('list')} {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(var(--item-width), 1fr));
grid-auto-rows: var(--item-height);
grid-gap: var(--grid-spacing);
padding: var(--grid-spacing);
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin;
}
.${cn('Gallery')} > .${cn('list')} > a {
position: relative;
display: block;
background: none;
border: var(--item-border-size) solid transparent;
padding: 0;
background: #222;
outline: none;
}
.${cn('Gallery')} > .${cn('list')} > a.${cn('active')} {
border-color: var(--active-color);
background: var(--active-color);
}
.${cn('Gallery')} > .${cn('list')} > a.${cn('selected')}:after {
content: '';
display: block;
box-sizing: border-box;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: calc(100% + 10px);
height: calc(100% + 10px);
border: 2px solid #fff;
pointer-events: none;
}
.${cn('Gallery')} > .${cn('list')} > a > img {
display: block;
width: 100%;
height: calc(var(--item-height) - var(--item-meta-height) - (var(--item-border-size) * 2));
object-fit: contain;
border-radius: 2px;
}
.${cn('Gallery')} > .${cn('list')} > a > .${cn('meta')} {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: var(--item-meta-height);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: calc(var(--item-meta-height) * 0.73) !important;
line-height: 1;
background: #0003;
text-shadow: 1px 1px #0003, -1px -1px #0003, 1px -1px #0003, -1px 1px #0003,
0px 1px #0003, 0px -1px #0003, 1px 0px #0003, -1px 0px #0003;
white-space: nowrap;
overflow: hidden;
pointer-events: none;
}
.${cn('Gallery')} > .${cn('list')} > a.${cn('active')} > .${cn('meta')} {
color: #222;
text-shadow: none;
background: #0001;
}
.${cn('Gallery')} > .${cn('list')} > a > .${cn('video-type')} {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: .5em .5em;
font-size: 12px !important;
text-transform: uppercase;
font-weight: bold;
line-height: 1;
color: #222;
background: #eeeeee88;
border-radius: 3px;
border: 1px solid #0000002e;
background-clip: padding-box;
pointer-events: none;
}
.${cn('Gallery')} > .${cn('list')} > a > .${cn('replies')} {
position: absolute;
bottom: calc(var(--item-meta-height) + 2px);
left: 0;
width: 100%;
display: flex;
justify-content: center;
flex-wrap: wrap-reverse;
}
.${cn('Gallery')} > .${cn('list')} > a > .${cn('replies')} > div {
width: 6px;
height: 6px;
margin: 1px;
background: var(--active-color);
background-clip: padding-box;
border: 1px solid #0008;
}
.${cn('Gallery')} > .${cn('meta')} {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr;
}
.${cn('Gallery')} > .${cn('meta')} > * {
display: flex;
align-items: center;
font-size: calc(var(--list-meta-height) * 0.7) !important;
margin: 0 .3em;
}
.${cn('Gallery')} > .${cn('meta')} > .${cn('actions')} > button,
.${cn('Gallery')} > .${cn('meta')} > .${cn('actions')} > button:active {
color: #eee;
background: #333;
border: 0;
outline: 0;
border-radius: 2px;
}
.${cn('Gallery')} > .${cn('meta')} > .${cn('position')} > .${cn('current')} {
font-weight: bold;
}
.${cn('Gallery')} > .${cn('meta')} > .${cn('position')} > .${cn('separator')} {
font-size: 1.1em !important;
margin: 0 0.15em;
}
.${cn('MediaView')} {
position: absolute;
top: 0; right: 0;
max-width: calc(100% - var(--media-list-width));
max-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
align-content: center;
justify-content: center;
}
.${cn('MediaView')} > * {
max-width: 100%;
max-height: 100vh;
}
.${cn('MediaView')}.${cn('expanded')} {
max-width: 100%;
width: 100vw; height: 100vh;
background: #000d;
z-index: 1000;
}
.${cn('MediaView')}.${cn('expanded')} > .${cn('MediaViewVideo')} {
width: 100%; height: 100%;
}
.${cn('MediaView')} > .${cn('spinner-wrapper')} {
align-self: flex-end;
flex: 0 0 auto;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px !important;
background: #18181c;
}
.${cn('MediaView')} > .${cn('spinner-wrapper')} + * { visibility: hidden; }
.${cn('MediaViewImage')} { display: block; }
.${cn('MediaViewVideo')} {
--timeline-max-size: 40px;
--timeline-min-size: 20px;
position: relative;
display: flex;
max-width: 100%;
max-height: 100vh;
align-items: center;
justify-content: center;
}
.${cn('MediaViewVideo')} > video {
display: block;
max-width: 100%;
max-height: calc(100vh - var(--timeline-min-size));
margin: 0 auto var(--timeline-min-size);
outline: none;
background: #000d;
}
.${cn('MediaViewVideo')} > .${cn('timeline')} {
position: absolute;
left: 0; bottom: 0;
width: 100%;
height: var(--timeline-max-size);
font-size: 14px !important;
line-height: 1;
color: #eee;
background: #111c;
border: 1px solid #111c;
transition: height 100ms ease-out;
user-select: none;
}
.${cn('MediaViewVideo')}:not(:hover) > .${cn('timeline')},
.${cn('MediaViewVideo')}.${cn('zoomed')} > .${cn('timeline')} {
height: var(--timeline-min-size);
}
.${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('buffered-range')} {
position: absolute;
bottom: 0;
height: 100%;
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAFUlEQVQImWNgQAL/////TyqHgYEBAB5CD/FVFp/QAAAAAElFTkSuQmCC') left bottom repeat;
opacity: .17;
transition: right 200ms ease-out;
}
.${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('progress')} {
height: 100%;
background: #eee;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
.${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('elapsed')},
.${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('total')} {
position: absolute;
top: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 0 .2em;
text-shadow: 1px 1px #000, -1px -1px #000, 1px -1px #000, -1px 1px #000, 0px 1px #000, 0px -1px #000, 1px 0px #000, -1px 0px #000;
pointer-events: none;
}
.${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('elapsed')} {left: 0;}
.${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('total')} {right: 0;}
.${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('progress')} .${cn('elapsed')},
.${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('progress')} .${cn('total')} {
color: #111;
text-shadow: none;
}
.${cn('MediaViewVideo')} > .${cn('volume')} {
position: absolute;
right: 10px;
top: calc(25% - var(--timeline-min-size));
width: 30px;
height: 50%;
background: #111c;
border: 1px solid #111c;
transition: opacity 100ms linear;
}
.${cn('MediaViewVideo')}:not(:hover) > .${cn('volume')} {opacity: 0;}
.${cn('MediaViewVideo')} > .${cn('volume')} > .${cn('bar')} {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
background: #eee;
}
.${cn('MediaViewError')} {
display: flex;
align-items: center;
justify-content: center;
min-width: 400px;
min-height: 300px;
padding: 2em 2.5em;
background: #a34;
color: ##fff;
}
.${cn('Spinner')} {
width: 1.6em;
height: 1.6em;
animation: Spinner-rotate 500ms infinite linear;
border: 0.1em solid #fffa;
border-right-color: #1d1f21aa;
border-left-color: #1d1f21aa;
border-radius: 50%;
}
@keyframes Spinner-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`);