NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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); } } `);