// ==UserScript== // @name Thread Media Viewer // @description Comfy media browser and viewer for various discussion boards. // @version 2.0.0 // @namespace qimasho // @source https://github.com/qimasho/thread-media-viewer // @supportURL https://github.com/qimasho/thread-media-viewer/issues // @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 // @grant GM_openInTab // @license MIT // @downloadURL none // ==/UserScript== (() => { // src/lib/utils.ts function isOfType(value, condition) { return condition; } const ns = (name) => `_tmv_${name}`; function clamp(min5, value, max5) { return Math.max(min5, Math.min(max5, value)); } function withValue(callback) { return (event) => { const target = event.target; if (isOfType(target, target && (target.nodeName === "INPUT" || target.nodeName === "BUTTON"))) { callback(target.value); } }; } function getBoundingDocumentRect(element) { const {width, height, top, left, bottom, right} = element.getBoundingClientRect(); return { width, height, top: window.scrollY + top, left: window.scrollX + left, bottom: window.scrollY + bottom, right: window.scrollX + right }; } function scrollToView(element, { block = "start", behavior = "auto" } = {}) { if (!document.body.contains(element)) return; let container = element.parentElement; while (container) { if (isScrollableY(container)) break; else container = container.parentElement; } if (!container) return; const containerRect = getBoundingDocumentRect(container); const elementRect = getBoundingDocumentRect(element); const topOffset = elementRect.top - containerRect.top + (container === document.scrollingElement ? 0 : container.scrollTop); let requestedOffset; if (block === "start") requestedOffset = topOffset; else if (block === "center") requestedOffset = topOffset - container.clientHeight / 2 + element.offsetHeight / 2; else if (block === "end") requestedOffset = topOffset - container.clientHeight + element.offsetHeight; else requestedOffset = topOffset - block; container.scrollTo({top: requestedOffset, behavior}); } function isScrollableY(element) { if (element.scrollHeight === element.clientHeight) return false; if (getComputedStyle(element).overflowY === "hidden") return false; if (element.scrollTop > 0) return true; element.scrollTop = 1; if (element.scrollTop > 0) { element.scrollTop = 0; return true; } return false; } function formatSeconds(seconds) { let minutes = Math.floor(seconds / 60); let leftover = Math.round(seconds - minutes * 60); return `${String(minutes).padStart(2, "0")}:${String(leftover).padStart(2, "0")}`; } function throttle(fn, timeout = 100, noTrailing = false) { let timeoutID; let args; let context; let last = 0; function call() { fn.apply(context, args); last = Date.now(); timeoutID = context = args = null; } function throttled() { let delta = Date.now() - last; context = this; args = arguments; if (delta >= timeout) { throttled.cancel(); call(); } else if (!noTrailing && timeoutID == null) { timeoutID = setTimeout(call, timeout - delta); } } throttled.cancel = () => { if (timeoutID !== null) { clearTimeout(timeoutID); timeoutID = null; } }; throttled.flush = () => { if (timeoutID !== null) { clearTimeout(timeoutID); timeoutID = null; call(); } }; return throttled; } function keyEventId(event) { let key = String(event.key); const keyAsNumber = Number(event.key); const isNumpadKey = event.code.indexOf("Numpad") === 0; const isNumpadNumber = keyAsNumber >= 0 && keyAsNumber <= 9 && isNumpadKey; if (key === " " || isNumpadNumber) key = event.code; let id = ""; if (event.altKey) id += "Alt"; if (event.ctrlKey) id += id.length > 0 ? "+Ctrl" : "Ctrl"; if (event.shiftKey && (key.length > 1 || isNumpadKey)) id += id.length > 0 ? "+Shift" : "Shift"; if (key !== "Alt" && key !== "Ctrl" && key !== "Shift") id += (id.length > 0 ? "+" : "") + key; return id; } // src/lib/syncedStorage.ts function syncedStorage(localStorageKey, defaults) { const listeners = new Set(); let savingPromise = null; let storage = load(); function triggerListeners() { for (let callback of listeners) callback(storage); } function load() { let json = localStorage.getItem(localStorageKey); let data; try { data = json ? {...defaults, ...JSON.parse(json)} : {...defaults}; } catch (error) { data = {...defaults}; } return data; } function save() { if (savingPromise) return savingPromise; savingPromise = new Promise((resolve) => setTimeout(() => { localStorage.setItem(localStorageKey, JSON.stringify(storage)); savingPromise = null; resolve(); }, 10)); return savingPromise; } window.addEventListener("storage", throttle(() => { let newData = load(); let hasChanges = false; for (let key in newData) { if (newData[key] !== storage[key]) { hasChanges = true; storage[key] = newData[key]; } } if (hasChanges) triggerListeners(); }, 500)); const control = { _assign(obj) { Object.assign(storage, obj); save(); triggerListeners(); }, _reset() { control._assign(defaults); }, _subscribe(callback) { listeners.add(callback); return () => listeners.delete(callback); }, _unsubscribe(callback) { listeners.delete(callback); }, get _defaults() { return defaults; } }; return new Proxy(storage, { get(_, prop) { if (isOfType(prop, prop in control)) return control[prop]; if (isOfType(prop, prop in storage)) return storage[prop]; throw new Error(`SyncedStorage: property "${String(prop)}" does not exist in "${localStorageKey}" storage.`); }, set(_, prop, value) { if (isOfType(prop, prop in storage)) { storage[prop] = value; save(); triggerListeners(); return true; } throw new Error(`Trying to set an unknown "${localStorageKey}" storage property "${String(prop)}"`); } }); } // src/serializers.ts const SERIALIZERS = [ { urlMatches: /^boards\.4chan(nel)?.org/i, threadSerializer: { selector: ".board .thread", serializer: fortunePostSerializer }, catalogSerializer: { selector: "#threads", serializer: (thread) => thread.querySelector("a")?.href } }, { urlMatches: /^thebarchive\.com/i, threadSerializer: { selector: ".thread .posts", serializer: theBArchivePostSerializer }, catalogSerializer: { selector: "#thread_o_matic", serializer: (thread) => thread.querySelector("a.thread_image_link")?.href } } ]; function fortunePostSerializer(post) { const titleAnchor = post.querySelector(".fileText a"); const url = post.querySelector("a.fileThumb")?.href; const thumbnailUrl = post.querySelector("a.fileThumb img")?.src; const meta = post.querySelector(".fileText")?.textContent?.match(/\(([^\(\)]+ *, *\d+x\d+)\)/)?.[1]; const [size, dimensions] = meta?.split(",").map((str) => str.trim()) || []; const [width, height] = dimensions?.split("x").map((str) => parseInt(str, 10) || void 0) || []; const filename = titleAnchor?.title || titleAnchor?.textContent || url?.match(/\/([^\/]+)$/)?.[1]; if (!url || !thumbnailUrl || !filename) return null; return { media: [{url, thumbnailUrl, filename, size, width, height}], replies: post.querySelectorAll(".postInfo .backlink a.quotelink")?.length ?? 0 }; } function theBArchivePostSerializer(post) { const titleElement = post.querySelector(".post_file_filename"); const url = post.querySelector("a.thread_image_link")?.href; const thumbnailUrl = post.querySelector("img.post_image")?.src; const meta = post.querySelector(".post_file_metadata")?.textContent; const [size, dimensions] = meta?.split(",").map((str) => str.trim()) || []; const [width, height] = dimensions?.split("x").map((str) => parseInt(str, 10) || void 0) || []; const filename = titleElement?.title || titleElement?.textContent || url?.match(/\/([^\/]+)$/)?.[1]; if (!url || !thumbnailUrl || !filename) return null; return { media: [{url, size, width, height, thumbnailUrl, filename}], replies: post.querySelectorAll(".backlink_list a.backlink")?.length ?? 0 }; } // src/lib/preact.ts const h = preact.h; const render = preact.render; const createContext = preact.createContext; const useState = preactHooks.useState; const useEffect = preactHooks.useEffect; const useLayoutEffect = preactHooks.useLayoutEffect; const useRef = preactHooks.useRef; const useMemo = preactHooks.useMemo; const useCallback = preactHooks.useCallback; const useContext = preactHooks.useContext; // src/settings.ts const defaultSettings = { mediaListWidth: 640, mediaListHeight: 0.5, mediaListItemsPerRow: 3, volume: 0.5, fastForwardActivation: "hold", fastForwardRate: 5, adjustVolumeBy: 0.125, seekBy: 5, endTimeFormat: "total", fpmActivation: "hold", fpmVideoUpscaleThreshold: 0.5, fpmVideoUpscaleLimit: 2, fpmImageUpscaleThreshold: 0, fpmImageUpscaleLimit: 2, catalogNavigator: true, keyToggleUI: "`", keyNavLeft: "a", keyNavRight: "d", keyNavUp: "w", keyNavDown: "s", keyNavPageBack: "PageUp", keyNavPageForward: "PageDown", keyNavStart: "Home", keyNavEnd: "End", keyListViewToggle: "f", keyListViewLeft: "A", keyListViewRight: "D", keyListViewUp: "W", keyListViewDown: "S", keyViewClose: "F", keyViewFullPage: "Tab", keyViewFullScreen: "r", keyViewPause: "Space", keyViewFastForward: "Shift+Space", keyViewVolumeDown: "Q", keyViewVolumeUp: "E", keyViewSeekBack: "q", keyViewSeekForward: "e", keyViewSeekTo0: "0", keyViewSeekTo10: "1", keyViewSeekTo20: "2", keyViewSeekTo30: "3", keyViewSeekTo40: "4", keyViewSeekTo50: "5", keyViewSeekTo60: "6", keyViewSeekTo70: "7", keyViewSeekTo80: "8", keyViewSeekTo90: "9", keyCatalogOpenThread: "f", keyCatalogOpenThreadInNewTab: "Ctrl+F", keyCatalogOpenThreadInBackgroundTab: "F" }; const SettingsContext = createContext(null); function useSettings() { const syncedSettings = useContext(SettingsContext); if (!syncedSettings) throw new Error(); const [_, update] = useState(NaN); useEffect(() => { return syncedSettings._subscribe(() => update(NaN)); }, []); return syncedSettings; } const SettingsProvider = SettingsContext.Provider; // src/lib/mediaWatcher.ts class MediaWatcher { constructor(serializer2) { this.listeners = new Set(); this.mediaByURL = new Map(); this.media = []; this.destroy = () => { this.listeners.clear(); this.observer.disconnect(); }; this.serialize = () => { let addedMedia = []; let hasNewMedia = false; let hasChanges = false; for (let child of this.container.children) { const postContainer = child; const serializedPost = this.serializer.serializer(postContainer); if (serializedPost == null) continue; for (let serializedMedia of serializedPost.media) { const extension = String(serializedMedia.url.match(/\.([^.]+)$/)?.[1] || "").toLowerCase(); const mediaItem = { ...serializedMedia, extension, isVideo: !!extension.match(/webm|mp4/), isGif: extension === "gif", postContainer, replies: serializedPost.replies }; let existingItem = this.mediaByURL.get(mediaItem.url); if (existingItem) { if (JSON.stringify(existingItem) !== JSON.stringify(mediaItem)) { Object.assign(existingItem, mediaItem); hasChanges = true; } continue; } this.mediaByURL.set(mediaItem.url, mediaItem); addedMedia.push(mediaItem); hasNewMedia = true; } } if (hasNewMedia) this.media = this.media.concat(addedMedia); if (hasNewMedia || hasChanges) { for (let listener of this.listeners) listener(addedMedia, this.media); } }; this.subscribe = (callback) => { this.listeners.add(callback); return () => this.unsubscribe(callback); }; this.unsubscribe = (callback) => { this.listeners.delete(callback); }; this.serializer = serializer2; const container = document.querySelector(serializer2.selector); if (!container) throw new Error(`No elements matched by threadSelector: ${serializer2.selector}`); this.container = container; this.serialize(); this.observer = new MutationObserver(this.serialize); this.observer.observe(container, {childList: true, subtree: true}); } } // src/lib/catalogWatcher.ts class CatalogWatcher { constructor(serializer2) { this.listeners = new Set(); this.threads = []; this.destroy = () => { this.listeners.clear(); this.observer.disconnect(); }; this.serialize = () => { let newThreads = []; let hasChanges = false; for (let i = 0; i < this.container.children.length; i++) { const container = this.container.children[i]; const url = this.serializer.serializer(container); if (url) { newThreads.push({url, container}); if (this.threads[i]?.url !== url) hasChanges = true; } } if (hasChanges) { this.threads = newThreads; for (let listener of this.listeners) listener(this.threads); } }; this.subscribe = (callback) => { this.listeners.add(callback); return () => this.unsubscribe(callback); }; this.unsubscribe = (callback) => { this.listeners.delete(callback); }; this.serializer = serializer2; const container = document.querySelector(serializer2.selector); if (!container) throw new Error(`No elements matched by threadSelector: ${serializer2.selector}`); this.container = container; this.serialize(); this.observer = new MutationObserver(this.serialize); this.observer.observe(container, {childList: true, subtree: true}); } } // src/lib/hooks.ts function useForceUpdate() { const [_, setState] = useState(NaN); return () => setState(NaN); } const _useKey = (() => { const INTERACTIVE = {INPUT: true, TEXTAREA: true, SELECT: true}; let handlersByShortcut = { keydown: new Map(), keyup: new Map() }; function triggerHandlers(event) { if (INTERACTIVE[event.target.nodeName]) return; const eventType = event.type; let handlers = handlersByShortcut[eventType]?.get(keyEventId(event)); if (handlers && handlers.length > 0) { event.preventDefault(); event.stopImmediatePropagation(); event.stopPropagation(); handlers[handlers.length - 1](event); } } window.addEventListener("keydown", triggerHandlers); window.addEventListener("keyup", triggerHandlers); return function _useKey2(event, shortcut, handler) { useEffect(() => { if (!shortcut) return; let handlers = handlersByShortcut[event].get(shortcut); if (!handlers) { handlers = []; handlersByShortcut[event].set(shortcut, handlers); } handlers.push(handler); const nonNullHandlers = handlers; return () => { let indexOfHandler = nonNullHandlers.indexOf(handler); if (indexOfHandler >= 0) nonNullHandlers.splice(indexOfHandler, 1); }; }, [shortcut, handler]); }; })(); function useKey(shortcut, handler) { _useKey("keydown", shortcut, handler); } function useKeyUp(shortcut, handler) { _useKey("keyup", shortcut, handler); } function useWindowDimensions() { let [dimensions, setDimensions] = useState([window.innerWidth, window.innerHeight]); useEffect(() => { let handleResize = throttle(() => setDimensions([window.innerWidth, window.innerHeight]), 100); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); return dimensions; } function useElementSize(ref, box = "border-box", throttle2 = false) { const [sizes, setSizes] = useState([null, null]); useLayoutEffect(() => { if (!ref.current) throw new Error(); const checker = (entries) => { let lastEntry = entries[entries.length - 1]; if (box === "padding-box") { setSizes([ref.current.clientWidth, ref.current.clientHeight]); } else if (box === "content-box") { setSizes([lastEntry.contentRect.width, lastEntry.contentRect.height]); } else { setSizes([lastEntry.borderBoxSize.inlineSize, lastEntry.borderBoxSize.blockSize]); } }; const check = throttle2 !== false ? throttle(checker, throttle2) : checker; const resizeObserver = new ResizeObserver(check); resizeObserver.observe(ref.current); return () => resizeObserver.disconnect(); }, [box]); return sizes; } function useItemsPerRow(ref, throttle2 = false) { const [itemsPerRow, setItemsPerRow] = useState(0); useLayoutEffect(() => { const container = ref.current; if (!container) throw new Error(); const checker = () => { let currentTop = null; for (let i = 0; i < container.children.length; i++) { const item = container.children[i]; const rect = item.getBoundingClientRect(); if (currentTop != null && currentTop !== rect.top) { setItemsPerRow(i); return; } currentTop = rect.top; } setItemsPerRow(container.children.length); }; const check = throttle2 !== false ? throttle(checker, throttle2) : checker; const resizeObserver = new ResizeObserver(check); resizeObserver.observe(container); const childrenObserver = new MutationObserver(check); childrenObserver.observe(container, {childList: true}); return () => { resizeObserver.disconnect(); childrenObserver.disconnect(); }; }, []); return itemsPerRow; } const useGesture = (() => { let callbacksByGesture = new Map(); function startGesture({button, x, y}) { if (button !== 2) return; const gestureStart = {x, y}; window.addEventListener("mouseup", endGesture); function endGesture({button: button2, x: x2, y: y2}) { window.removeEventListener("mouseup", endGesture); if (button2 !== 2) return; const dragDistance = Math.hypot(x2 - gestureStart.x, y2 - gestureStart.y); if (dragDistance < 30) return; let gesture; if (Math.abs(gestureStart.x - x2) < dragDistance / 2) { gesture = gestureStart.y < y2 ? "down" : "up"; } else if (Math.abs(gestureStart.y - y2) < dragDistance / 2) { gesture = gestureStart.x < x2 ? "right" : "left"; } if (gesture) { let callbacks = callbacksByGesture.get(gesture); if (callbacks && callbacks.length > 0) callbacks[callbacks.length - 1](); const preventContext = (event) => event.preventDefault(); window.addEventListener("contextmenu", preventContext, {once: true}); setTimeout(() => window.removeEventListener("contextmenu", preventContext), 10); } } } window.addEventListener("mousedown", startGesture); return function _useKey2(gesture, callback) { useEffect(() => { if (!gesture) return; let callbacks = callbacksByGesture.get(gesture); if (!callbacks) { callbacks = []; callbacksByGesture.set(gesture, callbacks); } callbacks.push(callback); const nonNullHandlers = callbacks; return () => { let callbackIndex = nonNullHandlers.indexOf(callback); if (callbackIndex >= 0) nonNullHandlers.splice(callbackIndex, 1); }; }, [gesture, callback]); }; })(); // src/components/SideView.ts function SideView({onClose, children}) { return h("div", {class: ns("SideView")}, [h("button", {class: ns("close"), onClick: onClose}, "×"), children]); } SideView.styles = ` /* Scrollbars in chrome since it doesn't support scrollbar-width */ .${ns("SideView")}::-webkit-scrollbar { width: 10px; background-color: transparent; } .${ns("SideView")}::-webkit-scrollbar-track { border: 0; background-color: transparent; } .${ns("SideView")}::-webkit-scrollbar-thumb { border: 0; background-color: #6f6f70; } .${ns("SideView")} { position: fixed; bottom: 0; left: 0; width: var(--media-list-width); height: calc(100vh - var(--media-list-height)); padding: 1em 1.5em; color: #aaa; background: #161616; box-shadow: 0px 6px 0 3px #0003; overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; } .${ns("SideView")} .${ns("close")} { position: sticky; top: 0; float: right; width: 1em; height: 1em; margin: 0 -.5em 0 0; padding: 0; background: transparent; border: 0; color: #eee; font-size: 2em !important; line-height: 1; } .${ns("SideView")} > *:last-child { padding-bottom: 1em; } .${ns("SideView")} fieldset { border: 0; margin: 1em 0; padding: 0; } .${ns("SideView")} fieldset + fieldset { margin-top: 2em; } .${ns("SideView")} fieldset > legend { margin: 0 padding: 0; width: 100%; } .${ns("SideView")} fieldset > legend > .${ns("title")} { display: inline-block; font-size: 1.1em; color: #fff; min-width: 38%; text-align: right; font-weight: bold; vertical-align: middle; } .${ns("SideView")} fieldset > legend > .${ns("actions")} { display: inline-block; margin-left: 1em; } .${ns("SideView")} fieldset > legend > .${ns("actions")} > button { height: 2em; margin-right: .3em; } .${ns("SideView")} fieldset > article { display: flex; align-items: center; grid-gap: .5em 1em; } .${ns("SideView")} fieldset > * + article { margin-top: .8em; } .${ns("SideView")} fieldset > article > header { flex: 0 0 38%; text-align: right; color: #fff; } .${ns("SideView")} fieldset > article > section { flex: 1 1 0; } .${ns("SideView")} fieldset.${ns("-value-heavy")} > article > header { flex: 0 0 20%; } .${ns("SideView")} fieldset.${ns("-compact")} > article { flex-wrap: wrap; } .${ns("SideView")} fieldset.${ns("-compact")} legend { text-align: left; } .${ns("SideView")} fieldset.${ns("-compact")} article > header { flex: 1 1 100%; margin-left: 1.5em; text-align: left; } .${ns("SideView")} fieldset.${ns("-compact")} article > section { flex: 1 1 100%; margin-left: 3em; } `; // src/components/Settings.ts const {round} = Math; function Settings() { const settings9 = useSettings(); const containerRef = useRef(null); const [containerWidth] = useElementSize(containerRef, "content-box", 100); function handleShortcutsKeyDown(event) { const target = event.target; if (!isOfType(target, target?.nodeName === "INPUT")) return; if (target.name.indexOf("key") !== 0) return; if (event.key === "Shift") return; event.preventDefault(); event.stopPropagation(); settings9[target.name] = keyEventId(event); } function handleShortcutsMouseDown(event) { if (event.button !== 0) return; const target = event.target; if (!isOfType(target, target?.nodeName === "BUTTON")) return; const name = target.name; if (!isOfType(name, name in settings9) || name.indexOf("key") !== 0) return; if (target.value === "unbind") settings9[name] = null; else if (target.value === "reset") settings9[name] = defaultSettings[name]; } function shortcutsFieldset(title, shortcuts) { function all(action) { settings9._assign(shortcuts.reduce((acc, [name, title2, flag]) => { if ((action !== "unbind" || flag !== "required") && name.indexOf("key") === 0) { acc[name] = action === "reset" ? defaultSettings[name] : null; } return acc; }, {})); } return h("fieldset", {...compactPropsS, onKeyDown: handleShortcutsKeyDown, onMouseDown: handleShortcutsMouseDown}, [ h("legend", null, [ h("span", {class: ns("title")}, title), h("span", {class: ns("actions")}, [ h("button", {class: ns("reset"), title: "Reset category", onClick: () => all("reset")}, "↻ reset"), h("button", {class: ns("unbind"), title: "Unbind category", onClick: () => all("unbind")}, "⦸ unbind") ]) ]), shortcuts.map(([name, title2, flag]) => shortcutItem(name, title2, flag)) ]); } function shortcutItem(name, title, flag) { const isDefault = settings9[name] === defaultSettings[name]; return h("article", null, [ h("header", null, title), h("section", null, [ h("input", { type: "text", name, value: settings9[name] || "", placeholder: !settings9[name] ? "unbound" : void 0 }), h("button", { class: ns("reset"), name, value: "reset", title: isDefault ? "Default value" : "Reset to default", disabled: isDefault }, isDefault ? "⦿" : "↻"), flag === "required" ? h("button", {class: ns("unbind"), title: `Required, can't unbind`, disabled: true}, "⚠") : settings9[name] !== null && h("button", {class: ns("unbind"), name, value: "unbind", title: "Unbind"}, "⦸") ]) ]); } const compactPropsM = containerWidth && containerWidth < 450 ? {class: ns("-compact")} : null; const compactPropsS = containerWidth && containerWidth < 340 ? compactPropsM : null; return h("div", {class: ns("Settings"), ref: containerRef}, [ h("h1", null, ["Settings "]), h("button", {class: ns("defaults"), onClick: settings9._reset, title: "Reset all settings to default values."}, "↻ defaults"), h("fieldset", compactPropsM, [ h("article", null, [ h("header", null, "Media list width × height"), h("section", null, h("code", null, [ `${settings9.mediaListWidth}px × ${round(settings9.mediaListHeight * 100)}%`, " ", h("small", null, "(drag edges)") ])) ]), h("article", null, [ h("header", null, "Items per row"), h("section", null, [ h("input", { type: "range", min: 2, max: 6, step: 1, name: "mediaListItemsPerRow", value: settings9.mediaListItemsPerRow, onInput: withValue((value) => { const defaultValue = settings9._defaults.mediaListItemsPerRow; settings9.mediaListItemsPerRow = parseInt(value, 10) || defaultValue; }) }), " ", h("code", null, settings9.mediaListItemsPerRow) ]) ]) ]), h("fieldset", compactPropsM, [ h("legend", null, h("span", {class: ns("title")}, "Full page mode")), h("article", null, [ h("header", null, [ "Activation ", h("small", {class: ns("-muted")}, [ "key: ", h("kbd", {title: "Rebind below."}, `${settings9.keyViewFullPage}`) ]) ]), h("section", null, [ h("label", null, [ h("input", { type: "radio", name: "fpmActivation", value: "hold", checked: settings9.fpmActivation === "hold", onInput: () => settings9.fpmActivation = "hold" }), " hold" ]), h("label", null, [ h("input", { type: "radio", name: "fpmActivation", value: "toggle", checked: settings9.fpmActivation === "toggle", onInput: () => settings9.fpmActivation = "toggle" }), " toggle" ]) ]) ]), h("article", null, [ h("header", { title: `Upscale only videos that cover less than ${round(round(settings9.fpmVideoUpscaleThreshold * 100))}% of the available dimensions (width settings9.fpmVideoUpscaleThreshold = parseFloat(value) || 0) }), " ", h("code", null, settings9.fpmVideoUpscaleThreshold === 0 ? "⦸" : `${round(settings9.fpmVideoUpscaleThreshold * 100)}%`) ]) ]), h("article", null, [ h("header", { title: `Don't upscale videos more than ${settings9.fpmVideoUpscaleLimit}x of their original size.` }, [h("span", {class: ns("help-indicator")}), " Video upscale limit"]), h("section", null, [ h("input", { type: "range", min: 1, max: 10, step: 0.5, name: "fpmVideoUpscaleLimit", value: settings9.fpmVideoUpscaleLimit, onInput: withValue((value) => settings9.fpmVideoUpscaleLimit = parseInt(value, 10) || 0.025) }), " ", h("code", null, settings9.fpmVideoUpscaleLimit === 1 ? "⦸" : `${settings9.fpmVideoUpscaleLimit}x`) ]) ]), h("article", null, [ h("header", { class: ns("title"), title: `Upscale only images that cover less than ${round(round(settings9.fpmImageUpscaleThreshold * 100))}% of the available dimensions (width settings9.fpmImageUpscaleThreshold = parseFloat(value) || 0) }), " ", h("code", null, settings9.fpmImageUpscaleThreshold === 0 ? "⦸" : `${round(settings9.fpmImageUpscaleThreshold * 100)}%`) ]) ]), h("article", null, [ h("header", { title: `Don't upscale images more than ${settings9.fpmImageUpscaleLimit}x of their original size.` }, [h("span", {class: ns("help-indicator")}), " Image upscale limit"]), h("section", null, [ h("input", { type: "range", min: 1, max: 10, step: 0.5, name: "fpmImageUpscaleLimit", value: settings9.fpmImageUpscaleLimit, onInput: withValue((value) => settings9.fpmImageUpscaleLimit = parseInt(value, 10) || 0.025) }), " ", h("code", null, settings9.fpmImageUpscaleLimit === 1 ? "⦸" : `${settings9.fpmImageUpscaleLimit}x`) ]) ]) ]), h("fieldset", compactPropsM, [ h("legend", null, h("span", {class: ns("title")}, "Video player")), h("article", null, [ h("header", null, "Volume"), h("section", null, [ h("input", { type: "range", min: 0, max: 1, step: settings9.adjustVolumeBy, name: "volume", value: settings9.volume, onInput: withValue((value) => settings9.volume = parseFloat(value) || 0.025) }), " ", h("code", null, `${(settings9.volume * 100).toFixed(1)}%`) ]) ]), h("article", null, [ h("header", null, "Adjust volume by"), h("section", null, [ h("input", { type: "range", min: 0.025, max: 0.5, step: 0.025, name: "adjustVolumeBy", value: settings9.adjustVolumeBy, onInput: withValue((value) => settings9.adjustVolumeBy = parseFloat(value) || 0.025) }), " ", h("code", null, `${(settings9.adjustVolumeBy * 100).toFixed(1)}%`) ]) ]), h("article", null, [ h("header", null, "Seek by"), h("section", null, [ h("input", { type: "range", min: 1, max: 60, step: 1, name: "seekBy", value: settings9.seekBy, onInput: withValue((value) => settings9.seekBy = parseInt(value, 10) || 0.025) }), " ", h("code", null, `${settings9.seekBy} seconds`) ]) ]), h("article", null, [ h("header", null, "End time format"), h("section", null, [ h("label", null, [ h("input", { type: "radio", name: "endTimeFormat", value: "total", checked: settings9.endTimeFormat === "total", onInput: () => settings9.endTimeFormat = "total" }), " total" ]), h("label", null, [ h("input", { type: "radio", name: "endTimeFormat", value: "remaining", checked: settings9.endTimeFormat === "remaining", onInput: () => settings9.endTimeFormat = "remaining" }), " remaining" ]) ]) ]), h("article", null, [ h("header", null, [ "Fast forward activation", " ", h("small", {class: ns("-muted"), style: "white-space: nowrap"}, [ "key: ", h("kbd", {title: "Rebind below."}, `${settings9.keyViewFastForward}`) ]) ]), h("section", null, [ h("label", null, [ h("input", { type: "radio", name: "fastForwardActivation", value: "hold", checked: settings9.fastForwardActivation === "hold", onInput: () => settings9.fastForwardActivation = "hold" }), " hold" ]), h("label", null, [ h("input", { type: "radio", name: "fastForwardActivation", value: "toggle", checked: settings9.fastForwardActivation === "toggle", onInput: () => settings9.fastForwardActivation = "toggle" }), " toggle" ]) ]) ]), h("article", null, [ h("header", null, "Fast forward rate"), h("section", null, [ h("input", { type: "range", min: 1.5, max: 10, step: 0.5, name: "fastForwardRate", value: settings9.fastForwardRate, onInput: withValue((value) => settings9.fastForwardRate = Math.max(1, parseFloat(value) || 2)) }), " ", h("code", null, `${settings9.fastForwardRate.toFixed(1)}x`) ]) ]) ]), h("fieldset", null, [ h("legend", null, h("span", {class: ns("title")}, "Catalog navigator")), h("article", null, [ h("header", null, "Enabled"), h("section", null, [ h("input", { type: "checkbox", name: "catalogNavigator", value: "toggle", checked: settings9.catalogNavigator, onInput: (event) => settings9.catalogNavigator = event.target?.checked }) ]) ]) ]), shortcutsFieldset("Navigation shortcuts", [ ["keyToggleUI", "Toggle UI", "required"], ["keyNavLeft", "Select left"], ["keyNavRight", "Select right"], ["keyNavUp", "Select up"], ["keyNavDown", "Select down"], ["keyNavPageBack", "Page back"], ["keyNavPageForward", "Page forward"], ["keyNavStart", "To start"], ["keyNavEnd", "To end"] ]), shortcutsFieldset("Media list shortcuts", [ ["keyListViewToggle", "View selected"], ["keyListViewLeft", "Select left & view"], ["keyListViewRight", "Select right & view"], ["keyListViewUp", "Select up & view"], ["keyListViewDown", "Select down & view"] ]), shortcutsFieldset("Media view shortcuts", [ ["keyViewClose", "Close view"], ["keyViewFullPage", "Full page mode"], ["keyViewFullScreen", "Full screen mode"], ["keyViewPause", "Pause"], ["keyViewFastForward", "Fast forward"], ["keyViewVolumeDown", "Volume down"], ["keyViewVolumeUp", "Volume up"], ["keyViewSeekBack", "Seek back"], ["keyViewSeekForward", "Seek forward"], ["keyViewSeekTo0", "Seek to 0%"], ["keyViewSeekTo10", "Seek to 10%"], ["keyViewSeekTo20", "Seek to 20%"], ["keyViewSeekTo30", "Seek to 30%"], ["keyViewSeekTo40", "Seek to 40%"], ["keyViewSeekTo50", "Seek to 50%"], ["keyViewSeekTo60", "Seek to 60%"], ["keyViewSeekTo70", "Seek to 70%"], ["keyViewSeekTo80", "Seek to 80%"], ["keyViewSeekTo90", "Seek to 90%"] ]), shortcutsFieldset("Catalog shortcuts", [ ["keyCatalogOpenThread", "Open"], ["keyCatalogOpenThreadInNewTab", "Open in new tab"], ["keyCatalogOpenThreadInBackgroundTab", "Open in background tab"] ]) ]); } Settings.styles = ` .${ns("Settings")} .${ns("defaults")} { position: absolute; top: 1em; right: 4em; height: 2em; } .${ns("Settings")} label { margin-right: .5em; background: #fff1; padding: .3em; border-radius: 2px; } .${ns("Settings")} input::placeholder { font-style: italic; color: #000a; font-size: .9em; } .${ns("Settings")} button.${ns("reset")}:not(:disabled):hover { color: #fff; border-color: #1196bf; background: #1196bf; } .${ns("Settings")} button.${ns("unbind")}:not(:disabled):hover { color: #fff; border-color: #f44; background: #f44; } .${ns("Settings")} article button.${ns("reset")}, .${ns("Settings")} article button.${ns("unbind")} { margin-left: 0.3em; } `; // src/components/Help.ts function Help() { const s = useSettings(); return h("div", {class: ns("Help")}, [ h("h1", null, "Help"), h("fieldset", {class: ns("-value-heavy")}, [ h("article", null, [ h("header", null, "Registry"), h("section", null, h("a", {href: "https://greasyfork.org/en/scripts/408038-thread-media-viewer"}, "greasyfork.org/en/scripts/408038")) ]), h("article", null, [ h("header", null, "Repository"), h("section", null, h("a", {href: "https://github.com/qimasho/thread-media-viewer"}, "github.com/qimasho/thread-media-viewer")) ]), h("article", null, [ h("header", null, "Issues"), h("section", null, h("a", {href: "https://github.com/qimasho/thread-media-viewer/issues"}, "github.com/qimasho/thread-media-viewer/issues")) ]) ]), h("h2", null, "Mouse controls"), h("ul", {class: ns("-clean")}, [ h("li", null, ["Right button gesture ", h("kbd", null, "↑"), " to toggle media list."]), h("li", null, ["Right button gesture ", h("kbd", null, "↓"), " to close media view."]), h("li", null, [h("kbd", null, "click"), " on thumbnail (thread or list) to open media viewer."]), h("li", null, [h("kbd", null, "shift+click"), " on thumbnail (thread) to open both media view and list."]), h("li", null, [h("kbd", null, "double-click"), " to toggle fullscreen."]), h("li", null, [h("kbd", null, "mouse wheel"), " on video to change audio volume."]), h("li", null, [h("kbd", null, "mouse wheel"), " on timeline to seek video."]), h("li", null, [h("kbd", null, "mouse down"), " on image for 1:1 zoom and pan."]) ]), h("h2", null, "FAQ"), h("dl", null, [ h("dt", null, "Why does the page scroll when I'm navigating items?"), h("dd", null, "It scrolls to place the associated post right below the media list box."), h("dt", null, "What are the small squares at the bottom of thumbnails?"), h("dd", null, "Visualization of the number of replies the post has.") ]) ]); } // src/components/Changelog.ts function Changelog() { return h("div", {class: ns("Changelog")}, [ h("h1", null, "Changelog"), h("h2", null, h("code", null, ["2.0.0", h("span", {class: ns("-muted")}, " ⬩ "), h("small", null, "2020.09.11")])), h("ul", null, [ h("li", null, "Complete rewrite in TypeScript and restructure into a proper code base (", h("a", {href: "https://github.com/qimasho/thread-media-viewer"}, "github"), ")."), h("li", null, "Added catalog navigation to use same shortcuts to browse and open threads in catalogs."), h("li", null, "Added settings with knobs for pretty much everything."), h("li", null, "Added changelog (hi)."), h("li", null, `Further optimized all media viewing features and interactions so they are more robust, stable, and responsive (except enter/exit fullscreen, all glitchiness and slow transitions there are browser's fault and I can't do anything about it T.T).`) ]) ]); } // src/components/MediaList.ts const {max, min, round: round2} = Math; function MediaList({media, activeIndex, sideView, onActivation, onOpenSideView}) { const settings9 = useSettings(); const containerRef = useRef(null); const listRef = useRef(null); let [selectedIndex, setSelectedIndex] = useState(activeIndex); const [isDragged, setIsDragged] = useState(false); const itemsPerRow = settings9.mediaListItemsPerRow; if (selectedIndex == null) { const centerOffset = window.innerHeight / 2; let lastProximity = Infinity; for (let i = 0; i < media.length; i++) { const rect = media[i].postContainer.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 != null && selectedIndex >= 0) setSelectedIndex(selectedIndex); } function scrollToItem(index, behavior = "smooth") { const targetChild = listRef.current?.children[index]; if (isOfType(targetChild, targetChild != null)) { scrollToView(targetChild, {block: "center", behavior}); } } function selectAndScrollTo(index) { if (media.length > 0 && index >= 0 && index < media.length) { setSelectedIndex(index); scrollToItem(index); } } function initiateResize(event) { const target = event.target; const direction = target?.dataset.direction; if (event.detail === 2 || event.button !== 0 || !direction) return; event.preventDefault(); event.stopPropagation(); const initialDocumentCursor = document.documentElement.style.cursor; const resizeX = direction === "ew" || direction === "nwse"; const resizeY = direction === "ns" || direction === "nwse"; const initialCursorToRightEdgeDelta = containerRef.current ? event.clientX - containerRef.current.offsetWidth : 0; function handleMouseMove(event2) { const clampedListWidth = clamp(300, event2.clientX - initialCursorToRightEdgeDelta, window.innerWidth - 300); if (resizeX) settings9.mediaListWidth = clampedListWidth; const clampedListHeight = clamp(200 / window.innerHeight, event2.clientY / window.innerHeight, 1 - 200 / window.innerHeight); if (resizeY) settings9.mediaListHeight = clampedListHeight; } function handleMouseUp() { settings9.mediaListWidth = round2(settings9.mediaListWidth / 10) * 10; window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("mousemove", handleMouseMove); document.documentElement.style.cursor = initialDocumentCursor; setIsDragged(false); } document.documentElement.style.cursor = `${direction}-resize`; setIsDragged(true); window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mousemove", handleMouseMove); } useEffect(() => { if (activeIndex != null && activeIndex != selectedIndex) selectAndScrollTo(activeIndex); }, [activeIndex]); useEffect(() => { if (selectedIndex != null) scrollToItem(selectedIndex, "auto"); }, []); useEffect(() => { if (selectedIndex != null && media?.[selectedIndex]?.postContainer && containerRef.current) { let offset = getBoundingDocumentRect(containerRef.current).height; scrollToView(media[selectedIndex].postContainer, {block: round2(offset), behavior: "smooth"}); } }, [selectedIndex]); const selectUp = () => selectedIndex != null && selectAndScrollTo(max(selectedIndex - itemsPerRow, 0)); const selectDown = () => { if (selectedIndex == media.length - 1) { document.scrollingElement?.scrollTo({ top: document.scrollingElement.scrollHeight, behavior: "smooth" }); } if (selectedIndex != null) selectAndScrollTo(min(selectedIndex + itemsPerRow, media.length - 1)); }; const selectPrev = () => selectedIndex != null && selectAndScrollTo(max(selectedIndex - 1, 0)); const selectNext = () => selectedIndex != null && selectAndScrollTo(min(selectedIndex + 1, media.length - 1)); const selectPageBack = () => selectedIndex != null && selectAndScrollTo(max(selectedIndex - itemsPerRow * 3, 0)); const selectPageForward = () => selectedIndex != null && selectAndScrollTo(min(selectedIndex + itemsPerRow * 3, media.length)); const selectFirst = () => selectAndScrollTo(0); const selectLast = () => selectAndScrollTo(media.length - 1); const selectAndViewPrev = () => { if (selectedIndex != null) { const prevIndex = max(selectedIndex - 1, 0); selectAndScrollTo(prevIndex); onActivation(prevIndex); } }; const selectAndViewNext = () => { if (selectedIndex != null) { const nextIndex = min(selectedIndex + 1, media.length - 1); selectAndScrollTo(nextIndex); onActivation(nextIndex); } }; const selectAndViewUp = () => { if (selectedIndex != null) { const index = max(selectedIndex - itemsPerRow, 0); selectAndScrollTo(index); onActivation(index); } }; const selectAndViewDown = () => { if (selectedIndex != null) { const index = min(selectedIndex + itemsPerRow, media.length - 1); selectAndScrollTo(index); onActivation(index); } }; const toggleViewSelectedItem = () => onActivation(selectedIndex === activeIndex ? null : selectedIndex); useKey(settings9.keyNavLeft, selectPrev); useKey(settings9.keyNavRight, selectNext); useKey(settings9.keyNavUp, selectUp); useKey(settings9.keyNavDown, selectDown); useKey(settings9.keyListViewUp, selectAndViewUp); useKey(settings9.keyListViewDown, selectAndViewDown); useKey(settings9.keyListViewLeft, selectAndViewPrev); useKey(settings9.keyListViewRight, selectAndViewNext); useKey(settings9.keyListViewToggle, toggleViewSelectedItem); useKey(settings9.keyNavPageBack, selectPageBack); useKey(settings9.keyNavPageForward, selectPageForward); useKey(settings9.keyNavStart, selectFirst); useKey(settings9.keyNavEnd, selectLast); function mediaItem({url, thumbnailUrl, extension, isVideo, isGif, replies, size, width, height}, index) { let classNames = ""; if (selectedIndex === index) classNames += ns("selected"); if (activeIndex === index) classNames += ` ${ns("active")}`; function onClick(event) { event.preventDefault(); setSelectedIndex(index); onActivation(index); } let metaStr = size; if (width && height) { const widthAndHeight = `${width}×${height}`; metaStr = size ? `${size}, ${widthAndHeight}` : widthAndHeight; } return h("a", {key: url, href: url, class: classNames, onClick}, [ h("img", {src: thumbnailUrl}), metaStr && h("span", {class: ns("meta")}, metaStr), (isVideo || isGif) && h("span", {class: ns("video-type")}, null, extension), replies != null && replies > 0 && h("span", {class: ns("replies")}, null, Array(replies).fill(h("span", null))) ]); } function sideViewAction(name, title) { return h("button", {class: sideView === name && ns("-active"), onClick: () => onOpenSideView(name)}, title); } return h("div", {class: ns("MediaList"), ref: containerRef}, [ h("div", {class: ns("list"), ref: listRef}, media.map(mediaItem)), h("div", {class: ns("controls")}, [ h("div", {class: ns("actions")}, [ sideViewAction("settings", "⚙ settings"), sideViewAction("help", "? help"), sideViewAction("changelog", "☲ changelog") ]), h("div", {class: ns("position")}, [ h("span", {class: ns("current")}, selectedIndex ? selectedIndex + 1 : 0), h("span", {class: ns("separator")}, "/"), h("span", {class: ns("total")}, media.length) ]) ]), !isDragged && h("div", {class: ns("dragger-x"), ["data-direction"]: "ew", onMouseDown: initiateResize}), !isDragged && h("div", {class: ns("dragger-y"), ["data-direction"]: "ns", onMouseDown: initiateResize}), !isDragged && h("div", {class: ns("dragger-xy"), ["data-direction"]: "nwse", onMouseDown: initiateResize}) ]); } MediaList.styles = ` /* Scrollbars in chrome since it doesn't support scrollbar-width */ .${ns("MediaList")} > .${ns("list")}::-webkit-scrollbar { width: 10px; background-color: transparent; } .${ns("MediaList")} > .${ns("list")}::-webkit-scrollbar-track { border: 0; background-color: transparent;6F6F70 } .${ns("MediaList")} > .${ns("list")}::-webkit-scrollbar-thumb { border: 0; background-color: #6f6f70; } .${ns("MediaList")} { --item-border-size: 1px; --item-meta-height: 18px; --list-meta-height: 24px; --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; } .${ns("MediaList")} > .${ns("dragger-x")} { position: absolute; left: 100%; top: 0; width: 12px; height: 100%; cursor: ew-resize; z-index: 2; } .${ns("MediaList")} > .${ns("dragger-y")} { position: absolute; top: 100%; left: 0; width: 100%; height: 12px; cursor: ns-resize; z-index: 2; } .${ns("MediaList")} > .${ns("dragger-xy")} { position: absolute; bottom: -10px; right: -10px; width: 20px; height: 20px; cursor: nwse-resize; z-index: 2; } .${ns("MediaList")} > .${ns("list")} { display: grid; grid-template-columns: repeat(var(--media-list-items-per-row), 1fr); grid-auto-rows: var(--media-list-item-height); overflow-y: scroll; overflow-x: hidden; scrollbar-width: thin; } .${ns("MediaList")} > .${ns("list")} > a { position: relative; display: block; background: none; border: var(--item-border-size) solid transparent; padding: 0; background-color: #222; background-clip: padding-box; outline: none; } .${ns("MediaList")} > .${ns("list")} > a.${ns("selected")} { border-color: var(--active-color); } .${ns("MediaList")} > .${ns("list")} > a.${ns("active")} { background-color: var(--active-color); } .${ns("MediaList")} > .${ns("list")} > a.${ns("selected")}:after { content: ''; display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 1px solid #2225; pointer-events: none; } .${ns("MediaList")} > .${ns("list")} > a.${ns("active")}.${ns("selected")}:after { border-color: #222a; } .${ns("MediaList")} > .${ns("list")} > a > img { display: block; width: 100%; height: calc(var(--media-list-item-height) - var(--item-meta-height) - (var(--item-border-size) * 2)); background-clip: padding-box; object-fit: contain; } .${ns("MediaList")} > .${ns("list")} > a.${ns("active")} > img { border: 1px solid transparent; border-bottom: 0; } .${ns("MediaList")} > .${ns("list")} > a > .${ns("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.71); 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; } .${ns("MediaList")} > .${ns("list")} > a.${ns("active")} > .${ns("meta")} { color: #222; text-shadow: none; background: #0001; } .${ns("MediaList")} > .${ns("list")} > a > .${ns("video-type")} { display: block; 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: 2px; border: 1px solid #0000002e; background-clip: padding-box; pointer-events: none; } .${ns("MediaList")} > .${ns("list")} > a > .${ns("replies")} { display: block; position: absolute; bottom: calc(var(--item-meta-height) + 2px); left: 0; width: 100%; display: flex; justify-content: center; flex-wrap: wrap-reverse; } .${ns("MediaList")} > .${ns("list")} > a > .${ns("replies")} > span { display: block; width: 6px; height: 6px; margin: 1px; background: var(--active-color); background-clip: padding-box; border: 1px solid #0008; } .${ns("MediaList")} > .${ns("controls")} { display: grid; grid-template-columns: 1fr auto; grid-template-rows: 1fr; margin: 0 2px; font-size: calc(var(--list-meta-height) * 0.64); } .${ns("MediaList")} > .${ns("controls")} > * { display: flex; align-items: center; } .${ns("MediaList")} > .${ns("controls")} > .${ns("actions")} { min-width: 0; } .${ns("MediaList")} > .${ns("controls")} > .${ns("actions")} > button, .${ns("MediaList")} > .${ns("controls")} > .${ns("actions")} > button:active { color: #eee; background: #1c1c1c; border: 0; outline: 0; border-radius: 2px; font-size: .911em; line-height: 1; height: 20px; padding: 0 .5em; white-space: nowrap; overflow: hidden; } .${ns("MediaList")} > .${ns("controls")} > .${ns("actions")} > button:hover { color: #fff; background: #333; } .${ns("MediaList")} > .${ns("controls")} > .${ns("actions")} > button + button { margin-left: 2px; } .${ns("MediaList")} > .${ns("controls")} > .${ns("actions")} > button.${ns("-active")} { color: #222; background: #ccc; } .${ns("MediaList")} > .${ns("controls")} > .${ns("position")} { margin: 0 .4em; } .${ns("MediaList")} > .${ns("controls")} > .${ns("position")} > .${ns("current")} { font-weight: bold; } .${ns("MediaList")} > .${ns("controls")} > .${ns("position")} > .${ns("separator")} { font-size: 1.05em; margin: 0 0.15em; } `; // src/components/ErrorBox.ts function ErrorBox({error, message}) { const code = error?.code; const msg = error?.message || message; return h("div", {class: ns("ErrorBox")}, [ code != null && h("h1", null, `Error code: ${code}`), h("pre", null, h("code", null, `${msg ?? "Unknown error"}`)) ]); } ErrorBox.styles = ` .${ns("ErrorBox")} { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2em 2.5em; background: #a34; color: #fff; } .${ns("ErrorBox")} > h1 { font-size: 1.2em; margin: 0 0 1em; } .${ns("ErrorBox")} > pre { margin: 0; } `; // src/components/Spinner.ts function Spinner() { return h("div", {class: ns("Spinner")}); } Spinner.styles = ` .${ns("Spinner")} { width: 1.6em; height: 1.6em; } .${ns("Spinner")}::after { content: ''; display: block; width: 100%; height: 100%; 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); } } `; // src/components/MediaImage.ts const {min: min2, max: max2, round: round3} = Math; function MediaImage({ url, upscale = false, upscaleThreshold = 0, upscaleLimit = 2 }) { const containerRef = useRef(null); const imageRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [zoomPan, setZoomPan] = useState(false); const [containerWidth, containerHeight] = useElementSize(containerRef); useLayoutEffect(() => { const image = imageRef.current; if (error || !image) return; let checkId = null; const check = () => { if (image.naturalWidth > 0) setIsLoading(false); else checkId = setTimeout(check, 50); }; setError(null); setIsLoading(true); check(); return () => checkId != null && clearTimeout(checkId); }, [url, error]); useLayoutEffect(() => { const image = imageRef.current; if (!upscale || isLoading || !image || !containerWidth || !containerHeight) return; const naturalWidth = image.naturalWidth; const naturalHeight = image.naturalHeight; if (naturalWidth < containerWidth * upscaleThreshold && naturalHeight < containerHeight * upscaleThreshold) { const windowAspectRatio = containerWidth / containerHeight; const videoAspectRatio = naturalWidth / naturalHeight; let newHeight, newWidth; if (windowAspectRatio > videoAspectRatio) { newHeight = min2(naturalHeight * upscaleLimit, containerHeight); newWidth = round3(naturalWidth * (newHeight / naturalHeight)); } else { newWidth = min2(naturalWidth * upscaleLimit, containerWidth); newHeight = round3(naturalHeight * (newWidth / naturalWidth)); } image.setAttribute("width", `${newWidth}`); image.setAttribute("height", `${newHeight}`); } return () => { image.removeAttribute("width"); image.removeAttribute("height"); }; }, [isLoading, url, upscale, upscaleThreshold, upscaleLimit, containerWidth, containerHeight]); useLayoutEffect(() => { const container = containerRef.current; const image = imageRef.current; if (!zoomPan || !image || !container) return; const zoomMargin = 10; const previewRect = image.getBoundingClientRect(); const zoomFactor = image.naturalWidth / previewRect.width; const cursorAnchorX = previewRect.left + previewRect.width / 2; const cursorAnchorY = previewRect.top + previewRect.height / 2; const availableWidth = container.clientWidth; const availableHeight = container.clientHeight; const dragWidth = max2((previewRect.width - availableWidth / zoomFactor) / 2, 0); const dragHeight = max2((previewRect.height - availableHeight / zoomFactor) / 2, 0); const translateWidth = max2((image.naturalWidth - availableWidth) / 2, 0); const translateHeight = max2((image.naturalHeight - availableHeight) / 2, 0); Object.assign(image.style, { maxWidth: "none", maxHeight: "none", width: "auto", height: "auto", position: "fixed", top: "50%", left: "50%" }); const panTo = (x, y) => { const dragFactorX = dragWidth > 0 ? -((x - cursorAnchorX) / dragWidth) : 0; const dragFactorY = dragHeight > 0 ? -((y - cursorAnchorY) / dragHeight) : 0; const left = round3(min2(max2(dragFactorX * translateWidth, -translateWidth - zoomMargin), translateWidth + zoomMargin)); const top = round3(min2(max2(dragFactorY * translateHeight, -translateHeight - zoomMargin), translateHeight + zoomMargin)); image.style.transform = `translate(-50%, -50%) translate(${left}px, ${top}px)`; }; const handleMouseMove = (event) => { event.preventDefault(); event.stopPropagation(); panTo(event.clientX, event.clientY); }; const handleMouseUp = () => { image.style.cssText = ""; window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("mousemove", handleMouseMove); setZoomPan(false); }; panTo(zoomPan.initialX, zoomPan.initialY); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); }, [zoomPan]); function handleMouseDown(event) { if (event.button !== 0) return; event.preventDefault(); setZoomPan({initialX: event.clientX, initialY: event.clientY}); } if (error) return h(ErrorBox, {error}); let classNames = ns("MediaImage"); if (isLoading) classNames += ` ${ns("-loading")}`; if (zoomPan) classNames += ` ${ns("-zoom-pan")}`; return h("div", {class: classNames, ref: containerRef}, isLoading && h(Spinner, null), h("img", { ref: imageRef, onMouseDown: handleMouseDown, onError: () => setError(new Error("Image failed to load")), src: url })); } MediaImage.styles = ` .${ns("MediaImage")} { display: flex; align-items: center; justify-content: center; background: #000d; } .${ns("MediaImage")}.${ns("-zoom-pan")} { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; } .${ns("MediaImage")} > .${ns("Spinner")} { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 2em; } .${ns("MediaImage")} > img { display: block; max-width: 100%; max-height: 100vh; } .${ns("MediaImage")}.${ns("-loading")} > img { min-width: 200px; min-height: 200px; opacity: 0; } `; // src/components/MediaVideo.ts const {min: min3, max: max3, round: round4} = Math; function MediaVideo({ url, upscale = false, upscaleThreshold = 0.5, upscaleLimit = 2 }) { const settings9 = useSettings(); const containerRef = useRef(null); const videoRef = useRef(null); const volumeRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [hasAudio, setHasAudio] = useState(false); const [isFastForward, setIsFastForward] = useState(false); const [error, setError] = useState(null); const [containerWidth, containerHeight] = useElementSize(containerRef); useLayoutEffect(() => { const video = videoRef.current; if (error || !video) return; let checkId = null; const check = () => { if (video?.videoHeight > 0) { setHasAudio(video.audioTracks?.length > 0 || video.mozHasAudio); setIsLoading(false); } else { checkId = setTimeout(check, 50); } }; setError(null); setIsLoading(true); setHasAudio(false); setIsFastForward(false); check(); return () => checkId != null && clearTimeout(checkId); }, [url, error]); useLayoutEffect(() => { const container = containerRef.current; const video = videoRef.current; if (!upscale || isLoading || !video || !container || !containerWidth || !containerHeight) return; const naturalWidth = video.videoWidth; const naturalHeight = video.videoHeight; if (naturalWidth < containerWidth * upscaleThreshold && naturalHeight < containerHeight * upscaleThreshold) { const windowAspectRatio = containerWidth / containerHeight; const videoAspectRatio = naturalWidth / naturalHeight; let newHeight, newWidth; if (windowAspectRatio > videoAspectRatio) { newHeight = min3(naturalHeight * upscaleLimit, containerHeight); newWidth = round4(naturalWidth * (newHeight / naturalHeight)); } else { newWidth = min3(naturalWidth * upscaleLimit, containerWidth); newHeight = round4(naturalHeight * (newWidth / naturalWidth)); } video.style.cssText = `width:${newWidth}px;height:${newHeight}px`; } return () => { video.style.cssText = ""; }; }, [isLoading, url, upscale, upscaleThreshold, upscaleLimit, containerWidth, containerHeight]); function initializeVolumeDragging(event) { const volume = volumeRef.current; if (event.button !== 0 || !volume) return; event.preventDefault(); event.stopPropagation(); const pointerTimelineSeek = throttle((moveEvent) => { let {top, height} = getBoundingDocumentRect(volume); let pos = min3(max3(1 - (moveEvent.pageY - top) / height, 0), 1); settings9.volume = round4(pos / settings9.adjustVolumeBy) * settings9.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(); settings9.volume = min3(max3(settings9.volume + settings9.adjustVolumeBy * (event.deltaY > 0 ? -1 : 1), 0), 1); } ; const playPause = () => { const video = videoRef.current; if (video) { if (video.paused || video.ended) video.play(); else video.pause(); } }; const flashVolume = useMemo(() => { let timeoutId = null; return () => { const volume = volumeRef.current; if (timeoutId) clearTimeout(timeoutId); if (volume) volume.style.opacity = "1"; timeoutId = setTimeout(() => { if (volume) volume.style.cssText = ""; }, 400); }; }, []); useKey(settings9.keyViewPause, playPause); useKey(settings9.keyViewSeekBack, () => { const video = videoRef.current; if (video) video.currentTime = max3(video.currentTime - settings9.seekBy, 0); }); useKey(settings9.keyViewSeekForward, () => { const video = videoRef.current; if (video) video.currentTime = min3(video.currentTime + settings9.seekBy, video.duration); }); useKey(settings9.keyViewVolumeDown, () => { settings9.volume = max3(settings9.volume - settings9.adjustVolumeBy, 0); flashVolume(); }); useKey(settings9.keyViewVolumeUp, () => { settings9.volume = min3(settings9.volume + settings9.adjustVolumeBy, 1); flashVolume(); }); useKey(settings9.keyViewFastForward, (event) => { if (event.repeat) return; if (settings9.fastForwardActivation === "hold") setIsFastForward(true); else setIsFastForward((value) => !value); }); useKeyUp(settings9.keyViewFastForward, () => { if (settings9.fastForwardActivation === "hold") setIsFastForward(false); }); for (let index of [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]) { useKey(settings9[`keyViewSeekTo${index * 10}`], () => { const video = videoRef.current; if (video) { if (video.duration > 0) video.currentTime = video.duration * (index / 10); } }); } if (error) return h(ErrorBox, {error}); let classNames = ns("MediaVideo"); if (isLoading) classNames += ` ${ns("-loading")}`; return h("div", {class: ns("MediaVideo"), ref: containerRef, onMouseDown: playPause, onWheel: handleContainerWheel}, [ isLoading && h(Spinner, null), h("video", { ref: videoRef, autoplay: true, preload: false, controls: false, loop: true, volume: settings9.volume, playbackRate: isFastForward ? settings9.fastForwardRate : 1, onError: () => setError(new Error("Video failed to load")), src: url }), h(VideoTimeline, {videoRef}), h("div", { class: ns("volume"), ref: volumeRef, onMouseDown: initializeVolumeDragging, style: hasAudio ? "display: hidden" : "" }, h("div", { class: ns("bar"), style: `height: ${Number(settings9.volume) * 100}%` })) ]); } function VideoTimeline({videoRef}) { const settings9 = useSettings(); const [state, setState] = useState({progress: 0, elapsed: 0, remaining: 0, duration: 0}); const [bufferedRanges, setBufferedRanges] = useState([]); const timelineRef = useRef(null); useEffect(() => { const video = videoRef.current; const timeline = timelineRef.current; if (!video || !timeline) return; const handleTimeupdate = () => { setState({ progress: video.currentTime / video.duration, elapsed: video.currentTime, remaining: video.duration - video.currentTime, duration: video.duration }); }; const handleMouseDown = (event) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const wasPaused = video.paused; const pointerTimelineSeek = throttle((mouseEvent) => { video.pause(); let {left, width} = getBoundingDocumentRect(timeline); let pos = min3(max3((mouseEvent.pageX - left) / width, 0), 1); video.currentTime = pos * video.duration; }, 100); const unbind = () => { if (!wasPaused) video.play(); window.removeEventListener("mousemove", pointerTimelineSeek); window.removeEventListener("mouseup", unbind); }; window.addEventListener("mousemove", pointerTimelineSeek); window.addEventListener("mouseup", unbind); pointerTimelineSeek(event); }; const handleWheel = (event) => { event.preventDefault(); event.stopPropagation(); video.currentTime = video.currentTime + 5 * (event.deltaY > 0 ? 1 : -1); }; const 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); }; const progressInterval = setInterval(() => { handleProgress(); if (video.buffered.length > 0 && video.buffered.end(video.buffered.length - 1) == video.duration) { clearInterval(progressInterval); } }, 200); video.addEventListener("timeupdate", handleTimeupdate); timeline.addEventListener("wheel", handleWheel); timeline.addEventListener("mousedown", handleMouseDown); return () => { video.removeEventListener("timeupdate", handleTimeupdate); timeline.removeEventListener("wheel", handleWheel); timeline.removeEventListener("mousedown", handleMouseDown); }; }, []); const elapsedTime = formatSeconds(state.elapsed); const totalTime = settings9.endTimeFormat === "total" ? formatSeconds(state.duration) : `-${formatSeconds(state.remaining)}`; return h("div", {class: ns("timeline"), ref: timelineRef}, [ ...bufferedRanges.map(({start, end}) => h("div", { class: ns("buffered-range"), style: { left: `${start * 100}%`, right: `${100 - end * 100}%` } })), h("div", {class: ns("elapsed")}, elapsedTime), h("div", {class: ns("total")}, totalTime), h("div", {class: ns("progress"), style: `width: ${state.progress * 100}%`}, [ h("div", {class: ns("elapsed")}, elapsedTime), h("div", {class: ns("total")}, totalTime) ]) ]); } MediaVideo.styles = ` .${ns("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; background: #000d; } .${ns("MediaVideo")} > .${ns("Spinner")} { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 2em; } .${ns("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; } .${ns("MediaVideo")}.${ns("-loading")} > video { min-width: 200px; min-height: 200px; opacity: 0; } .${ns("MediaVideo")} > .${ns("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; } .${ns("MediaVideo")}:not(:hover) > .${ns("timeline")}, .${ns("MediaVideo")}.${ns("zoomed")} > .${ns("timeline")} { height: var(--timeline-min-size); } .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("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; } .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("progress")} { height: 100%; background: #eee; clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); } .${ns("MediaVideo")} > .${ns("timeline")} .${ns("elapsed")}, .${ns("MediaVideo")} > .${ns("timeline")} .${ns("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; } .${ns("MediaVideo")} > .${ns("timeline")} .${ns("elapsed")} {left: 0;} .${ns("MediaVideo")} > .${ns("timeline")} .${ns("total")} {right: 0;} .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("progress")} .${ns("elapsed")}, .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("progress")} .${ns("total")} { color: #111; text-shadow: none; } .${ns("MediaVideo")} > .${ns("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; } .${ns("MediaVideo")}:not(:hover) > .${ns("volume")} {opacity: 0;} .${ns("MediaVideo")} > .${ns("volume")} > .${ns("bar")} { position: absolute; left: 0; bottom: 0; width: 100%; background: #eee; } `; // src/components/MediaView.ts function MediaView({media: {url, isVideo}}) { const settings9 = useSettings(); const containerRef = useRef(null); const [isExpanded, setIsExpanded] = useState(false); const [isFullScreen, setIsFullScreen] = useState(false); const toggleFullscreen = () => { if (containerRef.current) { if (!document.fullscreenElement) { setIsFullScreen(true); containerRef.current.requestFullscreen().catch((error) => { setIsFullScreen(false); }); } else { setIsFullScreen(false); document.exitFullscreen(); } } }; useKey(settings9.keyViewFullScreen, toggleFullscreen); useKey(settings9.keyViewFullPage, (event) => { event.preventDefault(); if (event.repeat) return; if (settings9.fpmActivation === "hold") setIsExpanded(true); else setIsExpanded((value) => !value); }); useKeyUp(settings9.keyViewFullPage, () => { if (settings9.fpmActivation === "hold") setIsExpanded(false); }); let classNames = ns("MediaView"); if (isExpanded || isFullScreen) classNames += ` ${ns("-expanded")}`; return h("div", {class: classNames, ref: containerRef, onDblClick: toggleFullscreen}, isVideo ? h(MediaVideo, { key: url, url, upscale: isExpanded || isFullScreen, upscaleThreshold: settings9.fpmVideoUpscaleThreshold, upscaleLimit: settings9.fpmVideoUpscaleLimit }) : h(MediaImage, { key: url, url, upscale: isExpanded || isFullScreen, upscaleThreshold: settings9.fpmImageUpscaleThreshold, upscaleLimit: settings9.fpmImageUpscaleLimit })); } MediaView.styles = ` .${ns("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; } .${ns("MediaView")} > * { width: 100%; height: 100%; max-width: 100%; max-height: 100vh; } .${ns("MediaView")}.${ns("-expanded")} { max-width: 100%; width: 100vw; height: 100vh; z-index: 1000; } .${ns("MediaView")} > .${ns("ErrorBox")} { min-height: 200px; } `; // src/components/ThreadMediaViewer.ts const {round: round5} = Math; function ThreadMediaViewer({settings: settings9, watcher}) { const containerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [sideView, setSideView] = useState(null); const [activeIndex, setActiveIndex] = useState(null); const [windowWidth] = useWindowDimensions(); const forceUpdate = useForceUpdate(); useEffect(() => { return watcher.subscribe(forceUpdate); }, [watcher]); useEffect(() => { return settings9._subscribe(forceUpdate); }, [settings9]); useEffect(() => { const container = containerRef.current; if (container) { const cappedListWidth = clamp(300, settings9.mediaListWidth, window.innerWidth - 300); container.style.setProperty("--media-list-width", `${cappedListWidth}px`); const itemHeight = round5(cappedListWidth / settings9.mediaListItemsPerRow * 0.8); container.style.setProperty("--media-list-item-height", `${itemHeight}px`); const cappedListHeight = clamp(200 / window.innerHeight, settings9.mediaListHeight, 1 - 200 / window.innerHeight); container.style.setProperty("--media-list-height", `${cappedListHeight * 100}vh`); container.style.setProperty("--media-list-items-per-row", `${settings9.mediaListItemsPerRow}`); } }, [windowWidth, settings9.mediaListWidth, settings9.mediaListHeight, settings9.mediaListItemsPerRow]); useEffect(() => { function handleClick(event) { const target = event.target; if (!isOfType(target, !!target && "closest" in target)) return; const url = target?.closest("a")?.href; if (url && watcher.mediaByURL.has(url)) { const mediaIndex = watcher.media.findIndex((media) => media.url === url); if (mediaIndex != null) { event.stopPropagation(); event.preventDefault(); setActiveIndex(mediaIndex); if (event.shiftKey) setIsOpen(true); } } } watcher.container.addEventListener("click", handleClick); return () => { watcher.container.removeEventListener("click", handleClick); }; }, []); const closeSideView = () => setSideView(null); function toggleList() { setIsOpen((isOpen2) => { setSideView(null); return !isOpen2; }); } function onOpenSideView(newView) { setSideView((view) => view === newView ? null : newView); } useKey(settings9.keyToggleUI, toggleList); useKey(settings9.keyViewClose, () => setActiveIndex(null)); useGesture("up", toggleList); useGesture("down", () => setActiveIndex(null)); let SideViewContent; if (sideView === "help") SideViewContent = Help; if (sideView === "settings") SideViewContent = Settings; if (sideView === "changelog") SideViewContent = Changelog; return h(SettingsProvider, {value: settings9}, h("div", {class: `${ns("ThreadMediaViewer")} ${isOpen ? ns("-is-open") : ""}`, ref: containerRef}, [ isOpen && h(MediaList, { media: watcher.media, activeIndex, sideView, onActivation: setActiveIndex, onOpenSideView }), SideViewContent != null && h(SideView, {key: sideView, onClose: closeSideView}, h(SideViewContent, null)), activeIndex != null && watcher.media[activeIndex] && h(MediaView, {media: watcher.media[activeIndex]}) ])); } ThreadMediaViewer.styles = ` .${ns("ThreadMediaViewer")} { --media-list-width: 640px; --media-list-height: 50vh; --media-list-items-per-row: 3; --media-list-item-height: 160px; position: fixed; top: 0; left: 0; width: 100%; height: 0; } `; // src/components/CatalogNavigator.ts const {min: min4, max: max4, round: round6} = Math; function CatalogNavigator({settings: settings9, watcher}) { const catalogContainerRef = useRef(watcher.container); const itemsPerRow = useItemsPerRow(catalogContainerRef); const cursorRef = useRef(null); const [sideView, setSideView] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); const forceUpdate = useForceUpdate(); const [windowWidth, windowHeight] = useWindowDimensions(); const selectedThread = watcher.threads[selectedIndex]; const enabled = settings9.catalogNavigator; useEffect(() => watcher.subscribe(forceUpdate), [watcher]); useEffect(() => settings9._subscribe(forceUpdate), [settings9]); useEffect(() => { const cursor = cursorRef.current; const container = watcher.container; if (!cursor || !selectedThread || !enabled) return; const rect = getBoundingDocumentRect(selectedThread.container); Object.assign(cursor.style, { left: `${rect.left - 4}px`, top: `${rect.top - 4}px`, width: `${rect.width + 8}px`, height: `${rect.height + 8}px` }); scrollToView(selectedThread.container, {block: window.innerHeight / 2 - 200, behavior: "smooth"}); }, [selectedThread, windowWidth, itemsPerRow, enabled]); const clampIndex = (index) => setSelectedIndex(max4(0, min4(watcher.threads.length - 1, index))); const toggleSettings = () => setSideView(sideView ? null : "settings"); useKey(settings9.keyToggleUI, toggleSettings); useKey(enabled && settings9.keyNavLeft, () => clampIndex(selectedIndex - 1)); useKey(enabled && settings9.keyNavRight, () => clampIndex(selectedIndex + 1)); useKey(enabled && settings9.keyNavUp, () => clampIndex(selectedIndex - itemsPerRow)); useKey(enabled && settings9.keyNavDown, () => clampIndex(selectedIndex + itemsPerRow)); useKey(enabled && settings9.keyNavPageBack, () => clampIndex(selectedIndex - itemsPerRow * 3)); useKey(enabled && settings9.keyNavPageForward, () => clampIndex(selectedIndex + itemsPerRow * 3)); useKey(enabled && settings9.keyNavStart, () => clampIndex(0)); useKey(enabled && settings9.keyNavEnd, () => clampIndex(Infinity)); useKey(enabled && settings9.keyCatalogOpenThread, () => selectedThread && (location.href = selectedThread.url)); useKey(enabled && settings9.keyCatalogOpenThreadInNewTab, () => { if (selectedThread) GM_openInTab(selectedThread.url, {active: true}); }); useKey(settings9.keyCatalogOpenThreadInBackgroundTab, () => selectedThread && GM_openInTab(selectedThread.url)); useGesture("up", toggleSettings); let SideViewContent; if (sideView === "help") SideViewContent = Help; if (sideView === "settings") SideViewContent = Settings; if (sideView === "changelog") SideViewContent = Changelog; function sideViewAction(name, title) { return h("button", {class: sideView === name && ns("-active"), onClick: () => setSideView(name)}, title); } let classNames = ns("CatalogNavigator"); if (sideView) classNames += ` ${ns("-is-open")}`; return h(SettingsProvider, {value: settings9}, [ enabled && selectedThread && h("div", {class: ns("CatalogCursor"), ref: cursorRef}), SideViewContent && h("div", {class: classNames}, [ h(SideView, {key: sideView, onClose: () => setSideView(null)}, h(SideViewContent, null)), h("div", {class: ns("navigation")}, [ sideViewAction("settings", "⚙ settings"), sideViewAction("help", "? help"), sideViewAction("changelog", "☲ changelog") ]) ]) ]); } CatalogNavigator.styles = ` .${ns("CatalogCursor")} { position: absolute; border: 2px dashed #fff8; border-radius: 2px; transition: all 66ms cubic-bezier(0.25, 1, 0.5, 1); pointer-events: none; } .${ns("CatalogCursor")}:before { content: ''; display: block; width: 100%; height: 100%; border: 2px dashed #0006; border-radius: 2; } .${ns("CatalogNavigator")} { --media-list-width: 640px; --media-list-height: 50vh; position: fixed; top: 0; left: 0; width: 100%; height: 0; } .${ns("CatalogNavigator")} > .${ns("navigation")} { position: fixed; left: 2px; bottom: calc(var(--media-list-height) - 0.2em); } .${ns("CatalogNavigator")} > .${ns("navigation")} > button, .${ns("CatalogNavigator")} > .${ns("navigation")} > button:active { color: #eee; background: #1c1c1c; border: 0; outline: 0; border-radius: 2px; font-size: .911em; line-height: 1; height: 20px; padding: 0 .5em; white-space: nowrap; overflow: hidden; box-shadow: 0 0 0 2px #161616; } .${ns("CatalogNavigator")} > .${ns("navigation")} > button:hover { color: #fff; background: #333; } .${ns("CatalogNavigator")} > .${ns("navigation")} > button + button { margin-left: 2px; } .${ns("CatalogNavigator")} > .${ns("navigation")} > button.${ns("-active")} { color: #222; background: #ccc; } `; // src/styles.ts const componentStyles = [ ThreadMediaViewer, CatalogNavigator, ErrorBox, MediaImage, MediaList, MediaVideo, MediaView, Settings, SideView, Spinner ].map(({styles: styles2}) => styles2).join("\n"); const baseStyles = ` .${ns("CONTAINER")}, .${ns("CONTAINER")} *, .${ns("CONTAINER")} *:before, .${ns("CONTAINER")} *:after { box-sizing: border-box; font-family: inherit; line-height: 1.4; } .${ns("CONTAINER")} { font-family: arial, helvetica, sans-serif; font-size: 16px; color: #aaa; } .${ns("CONTAINER")} a { color: #c4b256 !important; } .${ns("CONTAINER")} a:hover { color: #fde981 !important; } .${ns("CONTAINER")} a:active { color: #000 !important; } .${ns("CONTAINER")} input, .${ns("CONTAINER")} button { box-sizing: border-box; display: inline-block; vertical-align: middle; margin: 0; padding: 0 0.3em; height: 1.6em; font-size: inherit; border-radius: 2px; } .${ns("CONTAINER")} input:focus { box-shadow: 0 0 0 3px #fff2; } .${ns("CONTAINER")} input[type=text] { border: 0 !important; width: 8em; font-family: "Lucida Console", Monaco, monospace; color: #222; } .${ns("CONTAINER")} input[type=text].small { width: 4em; } .${ns("CONTAINER")} input[type=text].large { width: 12em; } .${ns("CONTAINER")} input[type=range] { width: 10em; } .${ns("CONTAINER")} input[type=radio], .${ns("CONTAINER")} input[type=range], .${ns("CONTAINER")} input[type=checkbox] { padding: 0; } .${ns("CONTAINER")} button { color: #fff; background: transparent; border: 1px solid #333; } .${ns("CONTAINER")} button:not(:disabled):hover { color: #222; background: #fff; border-color: #fff; } .${ns("CONTAINER")} button:disabled { opacity: .5; border-color: transparent; } .${ns("CONTAINER")} h1, .${ns("CONTAINER")} h2, .${ns("CONTAINER")} h3 { margin: 0; font-weight: normal; color: #fff; } .${ns("CONTAINER")} * + h1, .${ns("CONTAINER")} * + h2, .${ns("CONTAINER")} * + h3 { margin-top: 1em; } .${ns("CONTAINER")} h1 { font-size: 1.5em !important; } .${ns("CONTAINER")} h2 { font-size: 1.2em !important; } .${ns("CONTAINER")} h3 { font-size: 1em !important; font-weight: bold; } .${ns("CONTAINER")} ul { list-style: square; padding-left: 1em; margin: 1em 0; } .${ns("CONTAINER")} ul.${ns("-clean")} { list-style: none; } .${ns("CONTAINER")} li { padding: 0.3em 0; list-style: inherit; } .${ns("CONTAINER")} code { font-family: "Lucida Console", Monaco, monospace; padding: 0; background-color: transparent; color: inherit; } .${ns("CONTAINER")} pre { white-space: pre-wrap; } .${ns("CONTAINER")} kbd { padding: .17em .2em; font-family: "Lucida Console", Monaco, monospace; color: #fff; font-size: .95em; border-radius: 2px; background: #363f44; text-shadow: -1px -1px #0006; border: 0; box-shadow: none; line-height: inherit; } .${ns("CONTAINER")} dl { margin: 1em 0; } .${ns("CONTAINER")} dt { font-weight: bold; } .${ns("CONTAINER")} dd { margin: .1em 0 .8em; color: #888; } .${ns("CONTAINER")} [title] { cursor: help; } .${ns("CONTAINER")} .${ns("help-indicator")} { display: inline-block; vertical-align: middle; background: #333; color: #aaa; border-radius: 50%; width: 1.3em; height: 1.3em; text-align: center; font-size: .8em; line-height: 1.3; } .${ns("CONTAINER")} .${ns("help-indicator")}::after { content: '?'; } .${ns("CONTAINER")} .${ns("-muted")} { opacity: .5; } `; GM_addStyle(baseStyles + componentStyles); // src/index.ts const serializer = SERIALIZERS.find((serializer2) => serializer2.urlMatches.exec(location.host + location.pathname)); if (serializer) { const {threadSerializer, catalogSerializer} = serializer; const settings9 = syncedStorage(ns("settings"), defaultSettings); let mediaWatcher2 = null; let catalogWatcher2 = null; const container = Object.assign(document.createElement("div"), {className: ns("CONTAINER")}); document.body.appendChild(container); const refreshMounts = throttle(() => { if (mediaWatcher2 && !document.body.contains(mediaWatcher2.container)) { render(null, container); mediaWatcher2.destroy(); mediaWatcher2 = null; } if (catalogWatcher2 && !document.body.contains(catalogWatcher2.container)) { render(null, container); catalogWatcher2.destroy(); catalogWatcher2 = null; } if (!mediaWatcher2 && !catalogWatcher2) { if (threadSerializer) { try { mediaWatcher2 = new MediaWatcher(threadSerializer); render(h(ThreadMediaViewer, {settings: settings9, watcher: mediaWatcher2}), container); } catch (error) { } } if (catalogSerializer) { try { catalogWatcher2 = new CatalogWatcher(catalogSerializer); render(h(CatalogNavigator, {settings: settings9, watcher: catalogWatcher2}), container); } catch (error) { } } } }, 100); new MutationObserver(refreshMounts).observe(document.body, {childList: true, subtree: true}); refreshMounts(); } })();