Thread Media Viewer

Comfy and efficient way how to navigate media files in a thread. Currently set up for 4chan and thebarchive.

当前为 2020-08-01 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.0.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);
				}
			}
		}
		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 gestures (right click and drag anywhere on a page)'),
		h('ul', {}, [
			h('li', {}, [h('code', {}, '↑'), ' Toggle media list.']),
			h('li', {}, [h('code', {}, '↓'), ' Close media view.']),
		]),
		h('h2', {}, 'Mouse controls'),
		h('ul', {}, [
			h('li', {}, [h('code', {}, 'wheel up/down video'), ' Audio volume.']),
			h('li', {}, [h('code', {}, 'wheel up/down timeline'), ' Seek video.']),
			h('li', {}, [h('code', {}, 'mouse down image'), ' 1:1 zoom with panning.']),
		]),
		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'), ' Move selector.']),
			h('li', {}, [h('code', {}, 'home/end'), ' Move selector to top/bottom.']),
			h('li', {}, [h('code', {}, 'pageUp/pageDown'), ' Move selector one screen up/down.']),
			h('li', {}, [h('code', {}, 'f'), ' Display selected item (toggle).']),
			h('li', {}, [h('code', {}, 'F'), ' Close current media view.']),
			h('li', {}, [h('code', {}, 'W/A/S/D'), ' Select and display item.']),
			h('li', {}, [h('code', {}, 'q/e'), ' Seek video backward/forward.']),
			h('li', {}, [h('code', {}, '0-9'), ' Seek video to a specific % (1=10%).']),
			h('li', {}, [h('code', {}, 'space'), ' Pause/Play.']),
			h('li', {}, [h('code', {}, 'shift+space'), ' Fast forward (x5).']),
			h('li', {}, [h('code', {}, 'Q/E'), ' Decrease/increase audio volume.']),
			h('li', {}, [
				h('code', {}, 'tab'),
				' Full page media view (also, videos that cover less than half of available space receive 2x zoom).',
			]),
		]),
		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); }
}
`);