cynical_slave / Thread Media Viewer

// ==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.
// @match    https://boards.4chan.org/*
// @match    https://boards.4channel.org/*
// @match    https://thebarchive.com/*
// @require  https://cdn.jsdelivr.net/npm/preact@10.4.6/dist/preact.min.js
// @require  https://cdn.jsdelivr.net/npm/preact@10.4.6/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(MediaList, {
				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 MediaList({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('MediaList'), 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(MediaError, {message: error.message || 'Error loading media'})
			: h(item.isVideo ? MediaVideo : MediaImage, {
					key: item.url,
					url: item.url,
					mediaRef: mediaElement,
					onReady: () => setDisplaySpinner(false),
					onError: (error) => {
						setDisplaySpinner(false);
						setError(error);
					},
			  }),
	]);
}

function MediaImage({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('MediaImage'), ref: imageRef, onError, src: url});
}

function MediaVideo({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('MediaVideo'), 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 MediaError({message = 'Error'}) {
	return h('div', {class: cn('MediaError')}, 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('MediaList')} {
	--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('MediaList')} > .${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('MediaList')} > .${cn('list')} > a {
	position: relative;
	display: block;
	background: none;
	border: var(--item-border-size) solid transparent;
	padding: 0;
	background: #222;
	outline: none;
}
.${cn('MediaList')} > .${cn('list')} > a.${cn('active')} {
	border-color: var(--active-color);
	background: var(--active-color);
}
.${cn('MediaList')} > .${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('MediaList')} > .${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('MediaList')} > .${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('MediaList')} > .${cn('list')} > a.${cn('active')} > .${cn('meta')} {
	color: #222;
	text-shadow: none;
	background: #0001;
}
.${cn('MediaList')} > .${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('MediaList')} > .${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('MediaList')} > .${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('MediaList')} > .${cn('meta')} {
	display: grid;
	grid-template-columns: 1fr auto;
	grid-template-rows: 1fr;
}
.${cn('MediaList')} > .${cn('meta')} > * {
	display: flex;
	align-items: center;
	font-size: calc(var(--list-meta-height) * 0.7) !important;
	margin: 0 .3em;
}
.${cn('MediaList')} > .${cn('meta')} > .${cn('actions')} > button,
.${cn('MediaList')} > .${cn('meta')} > .${cn('actions')} > button:active {
	color: #eee;
	background: #333;
	border: 0;
	outline: 0;
	border-radius: 2px;
}
.${cn('MediaList')} > .${cn('meta')} > .${cn('position')} > .${cn('current')} {
	font-weight: bold;
}
.${cn('MediaList')} > .${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('MediaVideo')} {
	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('MediaImage')} { display: block; }

.${cn('MediaVideo')} {
	--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('MediaVideo')} > 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('MediaVideo')} > .${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('MediaVideo')}:not(:hover) > .${cn('timeline')},
.${cn('MediaVideo')}.${cn('zoomed')} > .${cn('timeline')} {
	height: var(--timeline-min-size);
}
.${cn('MediaVideo')} > .${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('MediaVideo')} > .${cn('timeline')} > .${cn('progress')} {
	height: 100%;
	background: #eee;
	clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
.${cn('MediaVideo')} > .${cn('timeline')} .${cn('elapsed')},
.${cn('MediaVideo')} > .${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('MediaVideo')} > .${cn('timeline')} .${cn('elapsed')} {left: 0;}
.${cn('MediaVideo')} > .${cn('timeline')} .${cn('total')} {right: 0;}
.${cn('MediaVideo')} > .${cn('timeline')} > .${cn('progress')} .${cn('elapsed')},
.${cn('MediaVideo')} > .${cn('timeline')} > .${cn('progress')} .${cn('total')} {
	color: #111;
	text-shadow: none;
}

.${cn('MediaVideo')} > .${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('MediaVideo')}:not(:hover) > .${cn('volume')} {opacity: 0;}
.${cn('MediaVideo')} > .${cn('volume')} > .${cn('bar')} {
	position: absolute;
	left: 0;
	bottom: 0;
	width: 100%;
	background: #eee;
}

.${cn('MediaError')} {
	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); }
}
`);