您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Comfy and efficient way how to navigate media files in a thread. Currently set up for 4chan and thebarchive.
当前为
- // ==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/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(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); }
- }
- `);