Thread Media Viewer

Comfy media browser and viewer for various discussion boards.

目前为 2020-09-15 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Thread Media Viewer
  3. // @description Comfy media browser and viewer for various discussion boards.
  4. // @version 2.2.0
  5. // @namespace qimasho
  6. // @source https://github.com/qimasho/thread-media-viewer
  7. // @supportURL https://github.com/qimasho/thread-media-viewer/issues
  8. // @match https://boards.4chan.org/*
  9. // @match https://boards.4channel.org/*
  10. // @match https://thebarchive.com/*
  11. // @require https://cdn.jsdelivr.net/npm/preact@10.4.6/dist/preact.min.js
  12. // @require https://cdn.jsdelivr.net/npm/preact@10.4.6/hooks/dist/hooks.umd.js
  13. // @grant GM_addStyle
  14. // @grant GM_openInTab
  15. // @license MIT
  16. // ==/UserScript==
  17.  
  18. (() => {
  19. // src/lib/utils.ts
  20. function isOfType(value, condition) {
  21. return condition;
  22. }
  23. const ns = (name) => `_tmv_${name}`;
  24. function clamp(min5, value, max5) {
  25. return Math.max(min5, Math.min(max5, value));
  26. }
  27. function withValue(callback) {
  28. return (event) => {
  29. const target = event.target;
  30. if (isOfType(target, target && (target.nodeName === "INPUT" || target.nodeName === "BUTTON"))) {
  31. callback(target.value);
  32. }
  33. };
  34. }
  35. function getBoundingDocumentRect(element) {
  36. const {width, height, top, left, bottom, right} = element.getBoundingClientRect();
  37. return {
  38. width,
  39. height,
  40. top: window.scrollY + top,
  41. left: window.scrollX + left,
  42. bottom: window.scrollY + bottom,
  43. right: window.scrollX + right
  44. };
  45. }
  46. function scrollToView(element, {
  47. block = "start",
  48. behavior = "auto"
  49. } = {}) {
  50. if (!document.body.contains(element))
  51. return;
  52. let container = element.parentElement;
  53. while (container) {
  54. if (isScrollableY(container))
  55. break;
  56. else
  57. container = container.parentElement;
  58. }
  59. if (!container)
  60. return;
  61. const containerRect = getBoundingDocumentRect(container);
  62. const elementRect = getBoundingDocumentRect(element);
  63. const topOffset = elementRect.top - containerRect.top + (container === document.scrollingElement ? 0 : container.scrollTop);
  64. let requestedOffset;
  65. if (block === "start")
  66. requestedOffset = topOffset;
  67. else if (block === "center")
  68. requestedOffset = topOffset - container.clientHeight / 2 + element.offsetHeight / 2;
  69. else if (block === "end")
  70. requestedOffset = topOffset - container.clientHeight + element.offsetHeight;
  71. else
  72. requestedOffset = topOffset - block;
  73. container.scrollTo({top: requestedOffset, behavior});
  74. }
  75. function isScrollableY(element) {
  76. if (element.scrollHeight === element.clientHeight)
  77. return false;
  78. if (getComputedStyle(element).overflowY === "hidden")
  79. return false;
  80. if (element.scrollTop > 0)
  81. return true;
  82. element.scrollTop = 1;
  83. if (element.scrollTop > 0) {
  84. element.scrollTop = 0;
  85. return true;
  86. }
  87. return false;
  88. }
  89. function formatSeconds(seconds) {
  90. let minutes = Math.floor(seconds / 60);
  91. let leftover = Math.round(seconds - minutes * 60);
  92. return `${String(minutes).padStart(2, "0")}:${String(leftover).padStart(2, "0")}`;
  93. }
  94. function throttle(fn, timeout = 100, noTrailing = false) {
  95. let timeoutID;
  96. let args;
  97. let context;
  98. let last = 0;
  99. function call() {
  100. fn.apply(context, args);
  101. last = Date.now();
  102. timeoutID = context = args = null;
  103. }
  104. function throttled() {
  105. let delta = Date.now() - last;
  106. context = this;
  107. args = arguments;
  108. if (delta >= timeout) {
  109. throttled.cancel();
  110. call();
  111. } else if (!noTrailing && timeoutID == null) {
  112. timeoutID = setTimeout(call, timeout - delta);
  113. }
  114. }
  115. throttled.cancel = () => {
  116. if (timeoutID !== null) {
  117. clearTimeout(timeoutID);
  118. timeoutID = null;
  119. }
  120. };
  121. throttled.flush = () => {
  122. if (timeoutID !== null) {
  123. clearTimeout(timeoutID);
  124. timeoutID = null;
  125. call();
  126. }
  127. };
  128. return throttled;
  129. }
  130. function keyEventId(event) {
  131. let key = String(event.key);
  132. const keyAsNumber = Number(event.key);
  133. const isNumpadKey = event.code.indexOf("Numpad") === 0;
  134. const isNumpadNumber = keyAsNumber >= 0 && keyAsNumber <= 9 && isNumpadKey;
  135. if (key === " " || isNumpadNumber)
  136. key = event.code;
  137. let id = "";
  138. if (event.altKey)
  139. id += "Alt";
  140. if (event.ctrlKey)
  141. id += id.length > 0 ? "+Ctrl" : "Ctrl";
  142. if (event.shiftKey && (key.length > 1 || isNumpadKey))
  143. id += id.length > 0 ? "+Shift" : "Shift";
  144. if (key !== "Alt" && key !== "Ctrl" && key !== "Shift")
  145. id += (id.length > 0 ? "+" : "") + key;
  146. return id;
  147. }
  148.  
  149. // src/lib/syncedSettings.ts
  150. function syncedSettings(localStorageKey, defaults) {
  151. const listeners = new Set();
  152. let savingPromise = null;
  153. let settings11 = load();
  154. function triggerListeners() {
  155. for (let callback of listeners)
  156. callback(settings11);
  157. }
  158. function load() {
  159. let json = localStorage.getItem(localStorageKey);
  160. let data;
  161. try {
  162. data = json ? {...defaults, ...JSON.parse(json)} : {...defaults};
  163. } catch (error) {
  164. data = {...defaults};
  165. }
  166. return data;
  167. }
  168. function save() {
  169. if (savingPromise)
  170. return savingPromise;
  171. savingPromise = new Promise((resolve) => setTimeout(() => {
  172. localStorage.setItem(localStorageKey, JSON.stringify(settings11));
  173. savingPromise = null;
  174. resolve();
  175. }, 10));
  176. return savingPromise;
  177. }
  178. window.addEventListener("storage", throttle(() => {
  179. let newData = load();
  180. let hasChanges = false;
  181. for (let key in newData) {
  182. if (newData[key] !== settings11[key]) {
  183. hasChanges = true;
  184. settings11[key] = newData[key];
  185. }
  186. }
  187. if (hasChanges)
  188. triggerListeners();
  189. }, 500));
  190. const control = {
  191. _assign(obj) {
  192. Object.assign(settings11, obj);
  193. save();
  194. triggerListeners();
  195. },
  196. _reset() {
  197. control._assign(defaults);
  198. },
  199. _subscribe(callback) {
  200. listeners.add(callback);
  201. return () => listeners.delete(callback);
  202. },
  203. _unsubscribe(callback) {
  204. listeners.delete(callback);
  205. },
  206. get _defaults() {
  207. return defaults;
  208. }
  209. };
  210. return new Proxy(settings11, {
  211. get(_, prop) {
  212. if (isOfType(prop, prop in control))
  213. return control[prop];
  214. if (isOfType(prop, prop in settings11))
  215. return settings11[prop];
  216. throw new Error(`SyncedStorage: property "${String(prop)}" does not exist in "${localStorageKey}".`);
  217. },
  218. set(_, prop, value) {
  219. if (isOfType(prop, prop in settings11)) {
  220. settings11[prop] = value;
  221. save();
  222. triggerListeners();
  223. return true;
  224. }
  225. throw new Error(`Trying to set an unknown "${localStorageKey}" property "${String(prop)}"`);
  226. }
  227. });
  228. }
  229.  
  230. // src/serializers.ts
  231. const SERIALIZERS = [
  232. {
  233. urlMatches: /^boards\.4chan(nel)?.org/i,
  234. threadSerializer: {
  235. selector: ".board .thread",
  236. serializer: fortunePostSerializer
  237. },
  238. catalogSerializer: {
  239. selector: "#threads",
  240. serializer: (thread) => thread.querySelector("a")?.href
  241. }
  242. },
  243. {
  244. urlMatches: /^thebarchive\.com/i,
  245. threadSerializer: {
  246. selector: ".thread .posts",
  247. serializer: theBArchivePostSerializer
  248. },
  249. catalogSerializer: {
  250. selector: "#thread_o_matic",
  251. serializer: (thread) => thread.querySelector("a.thread_image_link")?.href
  252. }
  253. }
  254. ];
  255. function fortunePostSerializer(post) {
  256. const titleAnchor = post.querySelector(".fileText a");
  257. const url = post.querySelector("a.fileThumb")?.href;
  258. const thumbnailUrl = post.querySelector("a.fileThumb img")?.src;
  259. const meta = post.querySelector(".fileText")?.textContent?.match(/\(([^\(\)]+ *, *\d+x\d+)\)/)?.[1];
  260. const [size, dimensions] = meta?.split(",").map((str) => str.trim()) || [];
  261. const [width, height] = dimensions?.split("x").map((str) => parseInt(str, 10) || void 0) || [];
  262. const filename = titleAnchor?.title || titleAnchor?.textContent || url?.match(/\/([^\/]+)$/)?.[1];
  263. if (!url || !thumbnailUrl || !filename)
  264. return null;
  265. return {
  266. media: [{url, thumbnailUrl, filename, size, width, height}],
  267. replies: post.querySelectorAll(".postInfo .backlink a.quotelink")?.length ?? 0
  268. };
  269. }
  270. function theBArchivePostSerializer(post) {
  271. const titleElement = post.querySelector(".post_file_filename");
  272. const url = post.querySelector("a.thread_image_link")?.href;
  273. const thumbnailUrl = post.querySelector("img.post_image")?.src;
  274. const meta = post.querySelector(".post_file_metadata")?.textContent;
  275. const [size, dimensions] = meta?.split(",").map((str) => str.trim()) || [];
  276. const [width, height] = dimensions?.split("x").map((str) => parseInt(str, 10) || void 0) || [];
  277. const filename = titleElement?.title || titleElement?.textContent || url?.match(/\/([^\/]+)$/)?.[1];
  278. if (!url || !thumbnailUrl || !filename)
  279. return null;
  280. return {
  281. media: [{url, size, width, height, thumbnailUrl, filename}],
  282. replies: post.querySelectorAll(".backlink_list a.backlink")?.length ?? 0
  283. };
  284. }
  285.  
  286. // src/lib/preact.ts
  287. const h = preact.h;
  288. const render = preact.render;
  289. const createContext = preact.createContext;
  290. const useState = preactHooks.useState;
  291. const useEffect = preactHooks.useEffect;
  292. const useLayoutEffect = preactHooks.useLayoutEffect;
  293. const useRef = preactHooks.useRef;
  294. const useMemo = preactHooks.useMemo;
  295. const useCallback = preactHooks.useCallback;
  296. const useContext = preactHooks.useContext;
  297.  
  298. // src/settings.ts
  299. const defaultSettings = {
  300. lastAcknowledgedVersion: "2.2.0",
  301. mediaListWidth: 640,
  302. mediaListHeight: 0.5,
  303. mediaListItemsPerRow: 3,
  304. thumbnailFit: "contain",
  305. volume: 0.5,
  306. fastForwardActivation: "hold",
  307. fastForwardRate: 5,
  308. adjustVolumeBy: 0.125,
  309. adjustSpeedBy: 0.5,
  310. seekBy: 5,
  311. tinySeekBy: 0.033,
  312. endTimeFormat: "total",
  313. fpmActivation: "hold",
  314. fpmVideoUpscaleThreshold: 0.5,
  315. fpmVideoUpscaleLimit: 2,
  316. fpmImageUpscaleThreshold: 0,
  317. fpmImageUpscaleLimit: 2,
  318. catalogNavigator: true,
  319. keyToggleUI: "`",
  320. keyNavLeft: "a",
  321. keyNavRight: "d",
  322. keyNavUp: "w",
  323. keyNavDown: "s",
  324. keyNavPageBack: "PageUp",
  325. keyNavPageForward: "PageDown",
  326. keyNavStart: "Home",
  327. keyNavEnd: "End",
  328. keyListViewToggle: "f",
  329. keyListViewLeft: "A",
  330. keyListViewRight: "D",
  331. keyListViewUp: "W",
  332. keyListViewDown: "S",
  333. keyViewClose: "F",
  334. keyViewFullPage: "Tab",
  335. keyViewFullScreen: "r",
  336. keyViewPause: "Space",
  337. keyViewFastForward: "Shift+Space",
  338. keyViewVolumeDown: "Q",
  339. keyViewVolumeUp: "E",
  340. keyViewSpeedDown: "Alt+q",
  341. keyViewSpeedUp: "Alt+e",
  342. keyViewSpeedReset: "Alt+w",
  343. keyViewSeekBack: "q",
  344. keyViewSeekForward: "e",
  345. keyViewTinySeekBack: "Alt+a",
  346. keyViewTinySeekForward: "Alt+d",
  347. keyViewSeekTo0: "0",
  348. keyViewSeekTo10: "1",
  349. keyViewSeekTo20: "2",
  350. keyViewSeekTo30: "3",
  351. keyViewSeekTo40: "4",
  352. keyViewSeekTo50: "5",
  353. keyViewSeekTo60: "6",
  354. keyViewSeekTo70: "7",
  355. keyViewSeekTo80: "8",
  356. keyViewSeekTo90: "9",
  357. keyCatalogOpenThread: "f",
  358. keyCatalogOpenThreadInNewTab: "Ctrl+F",
  359. keyCatalogOpenThreadInBackgroundTab: "F"
  360. };
  361. const SettingsContext = createContext(null);
  362. function useSettings() {
  363. const syncedSettings3 = useContext(SettingsContext);
  364. if (!syncedSettings3)
  365. throw new Error();
  366. const [_, update] = useState(NaN);
  367. useEffect(() => {
  368. return syncedSettings3._subscribe(() => update(NaN));
  369. }, []);
  370. return syncedSettings3;
  371. }
  372. const SettingsProvider = SettingsContext.Provider;
  373.  
  374. // src/lib/mediaWatcher.ts
  375. class MediaWatcher {
  376. constructor(serializer2) {
  377. this.listeners = new Set();
  378. this.mediaByURL = new Map();
  379. this.media = [];
  380. this.destroy = () => {
  381. this.listeners.clear();
  382. this.observer.disconnect();
  383. };
  384. this.serialize = () => {
  385. let addedMedia = [];
  386. let hasNewMedia = false;
  387. let hasChanges = false;
  388. for (let child of this.container.children) {
  389. const postContainer = child;
  390. const serializedPost = this.serializer.serializer(postContainer);
  391. if (serializedPost == null)
  392. continue;
  393. for (let serializedMedia of serializedPost.media) {
  394. const extension = String(serializedMedia.url.match(/\.([^.]+)$/)?.[1] || "").toLowerCase();
  395. const mediaItem = {
  396. ...serializedMedia,
  397. extension,
  398. isVideo: !!extension.match(/webm|mp4/),
  399. isGif: extension === "gif",
  400. postContainer,
  401. replies: serializedPost.replies
  402. };
  403. let existingItem = this.mediaByURL.get(mediaItem.url);
  404. if (existingItem) {
  405. if (JSON.stringify(existingItem) !== JSON.stringify(mediaItem)) {
  406. Object.assign(existingItem, mediaItem);
  407. hasChanges = true;
  408. }
  409. continue;
  410. }
  411. this.mediaByURL.set(mediaItem.url, mediaItem);
  412. addedMedia.push(mediaItem);
  413. hasNewMedia = true;
  414. }
  415. }
  416. if (hasNewMedia)
  417. this.media = this.media.concat(addedMedia);
  418. if (hasNewMedia || hasChanges) {
  419. for (let listener of this.listeners)
  420. listener(addedMedia, this.media);
  421. }
  422. };
  423. this.subscribe = (callback) => {
  424. this.listeners.add(callback);
  425. return () => this.unsubscribe(callback);
  426. };
  427. this.unsubscribe = (callback) => {
  428. this.listeners.delete(callback);
  429. };
  430. this.serializer = serializer2;
  431. const container = document.querySelector(serializer2.selector);
  432. if (!container)
  433. throw new Error(`No elements matched by threadSelector: ${serializer2.selector}`);
  434. this.container = container;
  435. this.serialize();
  436. this.observer = new MutationObserver(this.serialize);
  437. this.observer.observe(container, {childList: true, subtree: true});
  438. }
  439. }
  440.  
  441. // src/lib/catalogWatcher.ts
  442. class CatalogWatcher {
  443. constructor(serializer2) {
  444. this.listeners = new Set();
  445. this.threads = [];
  446. this.destroy = () => {
  447. this.listeners.clear();
  448. this.observer.disconnect();
  449. };
  450. this.serialize = () => {
  451. let newThreads = [];
  452. let hasChanges = false;
  453. for (let i = 0; i < this.container.children.length; i++) {
  454. const container = this.container.children[i];
  455. const url = this.serializer.serializer(container);
  456. if (url) {
  457. newThreads.push({url, container});
  458. if (this.threads[i]?.url !== url)
  459. hasChanges = true;
  460. }
  461. }
  462. if (hasChanges) {
  463. this.threads = newThreads;
  464. for (let listener of this.listeners)
  465. listener(this.threads);
  466. }
  467. };
  468. this.subscribe = (callback) => {
  469. this.listeners.add(callback);
  470. return () => this.unsubscribe(callback);
  471. };
  472. this.unsubscribe = (callback) => {
  473. this.listeners.delete(callback);
  474. };
  475. this.serializer = serializer2;
  476. const container = document.querySelector(serializer2.selector);
  477. if (!container)
  478. throw new Error(`No elements matched by threadSelector: ${serializer2.selector}`);
  479. this.container = container;
  480. this.serialize();
  481. this.observer = new MutationObserver(this.serialize);
  482. this.observer.observe(container, {childList: true, subtree: true});
  483. }
  484. }
  485.  
  486. // src/lib/hooks.ts
  487. function useForceUpdate() {
  488. const [_, setState] = useState(NaN);
  489. return () => setState(NaN);
  490. }
  491. const _useKey = (() => {
  492. const INTERACTIVE = {INPUT: true, TEXTAREA: true, SELECT: true};
  493. let handlersByShortcut = {
  494. keydown: new Map(),
  495. keyup: new Map()
  496. };
  497. function triggerHandlers(event) {
  498. if (INTERACTIVE[event.target.nodeName])
  499. return;
  500. const eventType = event.type;
  501. let handlers = handlersByShortcut[eventType]?.get(keyEventId(event));
  502. if (handlers && handlers.length > 0) {
  503. event.preventDefault();
  504. event.stopImmediatePropagation();
  505. event.stopPropagation();
  506. handlers[handlers.length - 1](event);
  507. }
  508. }
  509. window.addEventListener("keydown", triggerHandlers);
  510. window.addEventListener("keyup", triggerHandlers);
  511. return function _useKey2(event, shortcut, handler) {
  512. useEffect(() => {
  513. if (!shortcut)
  514. return;
  515. let handlers = handlersByShortcut[event].get(shortcut);
  516. if (!handlers) {
  517. handlers = [];
  518. handlersByShortcut[event].set(shortcut, handlers);
  519. }
  520. handlers.push(handler);
  521. const nonNullHandlers = handlers;
  522. return () => {
  523. let indexOfHandler = nonNullHandlers.indexOf(handler);
  524. if (indexOfHandler >= 0)
  525. nonNullHandlers.splice(indexOfHandler, 1);
  526. };
  527. }, [shortcut, handler]);
  528. };
  529. })();
  530. function useKey(shortcut, handler) {
  531. _useKey("keydown", shortcut, handler);
  532. }
  533. function useKeyUp(shortcut, handler) {
  534. _useKey("keyup", shortcut, handler);
  535. }
  536. function useWindowDimensions() {
  537. let [dimensions, setDimensions] = useState([window.innerWidth, window.innerHeight]);
  538. useEffect(() => {
  539. let handleResize = throttle(() => setDimensions([window.innerWidth, window.innerHeight]), 100);
  540. window.addEventListener("resize", handleResize);
  541. return () => window.removeEventListener("resize", handleResize);
  542. }, []);
  543. return dimensions;
  544. }
  545. function useElementSize(ref, box = "border-box", throttle2 = false) {
  546. const [sizes, setSizes] = useState([null, null]);
  547. useLayoutEffect(() => {
  548. if (!ref.current)
  549. throw new Error();
  550. const checker = (entries) => {
  551. let lastEntry = entries[entries.length - 1];
  552. if (box === "padding-box") {
  553. setSizes([ref.current.clientWidth, ref.current.clientHeight]);
  554. } else if (box === "content-box") {
  555. setSizes([lastEntry.contentRect.width, lastEntry.contentRect.height]);
  556. } else {
  557. setSizes([lastEntry.borderBoxSize.inlineSize, lastEntry.borderBoxSize.blockSize]);
  558. }
  559. };
  560. const check = throttle2 !== false ? throttle(checker, throttle2) : checker;
  561. const resizeObserver = new ResizeObserver(check);
  562. resizeObserver.observe(ref.current);
  563. return () => resizeObserver.disconnect();
  564. }, [box]);
  565. return sizes;
  566. }
  567. function useItemsPerRow(ref, throttle2 = false) {
  568. const [itemsPerRow, setItemsPerRow] = useState(0);
  569. useLayoutEffect(() => {
  570. const container = ref.current;
  571. if (!container)
  572. throw new Error();
  573. const checker = () => {
  574. let currentTop = null;
  575. for (let i = 0; i < container.children.length; i++) {
  576. const item = container.children[i];
  577. const rect = item.getBoundingClientRect();
  578. if (currentTop != null && currentTop !== rect.top) {
  579. setItemsPerRow(i);
  580. return;
  581. }
  582. currentTop = rect.top;
  583. }
  584. setItemsPerRow(container.children.length);
  585. };
  586. const check = throttle2 !== false ? throttle(checker, throttle2) : checker;
  587. const resizeObserver = new ResizeObserver(check);
  588. resizeObserver.observe(container);
  589. const childrenObserver = new MutationObserver(check);
  590. childrenObserver.observe(container, {childList: true});
  591. return () => {
  592. resizeObserver.disconnect();
  593. childrenObserver.disconnect();
  594. };
  595. }, []);
  596. return itemsPerRow;
  597. }
  598. const useGesture = (() => {
  599. let callbacksByGesture = new Map();
  600. function startGesture({button, x, y}) {
  601. if (button !== 2)
  602. return;
  603. const gestureStart = {x, y};
  604. window.addEventListener("mouseup", endGesture);
  605. function endGesture({button: button2, x: x2, y: y2}) {
  606. window.removeEventListener("mouseup", endGesture);
  607. if (button2 !== 2)
  608. return;
  609. const dragDistance = Math.hypot(x2 - gestureStart.x, y2 - gestureStart.y);
  610. if (dragDistance < 30)
  611. return;
  612. let gesture;
  613. if (Math.abs(gestureStart.x - x2) < dragDistance / 2) {
  614. gesture = gestureStart.y < y2 ? "down" : "up";
  615. } else if (Math.abs(gestureStart.y - y2) < dragDistance / 2) {
  616. gesture = gestureStart.x < x2 ? "right" : "left";
  617. }
  618. if (gesture) {
  619. let callbacks = callbacksByGesture.get(gesture);
  620. if (callbacks && callbacks.length > 0)
  621. callbacks[callbacks.length - 1]();
  622. const preventContext = (event) => event.preventDefault();
  623. window.addEventListener("contextmenu", preventContext, {once: true});
  624. setTimeout(() => window.removeEventListener("contextmenu", preventContext), 10);
  625. }
  626. }
  627. }
  628. window.addEventListener("mousedown", startGesture);
  629. return function useGesture2(gesture, callback) {
  630. useEffect(() => {
  631. if (!gesture)
  632. return;
  633. let callbacks = callbacksByGesture.get(gesture);
  634. if (!callbacks) {
  635. callbacks = [];
  636. callbacksByGesture.set(gesture, callbacks);
  637. }
  638. callbacks.push(callback);
  639. const nonNullHandlers = callbacks;
  640. return () => {
  641. let callbackIndex = nonNullHandlers.indexOf(callback);
  642. if (callbackIndex >= 0)
  643. nonNullHandlers.splice(callbackIndex, 1);
  644. };
  645. }, [gesture, callback]);
  646. };
  647. })();
  648.  
  649. // src/components/SideView.ts
  650. function SideView({onClose, children}) {
  651. return h("div", {class: ns("SideView")}, [h("button", {class: ns("close"), onClick: onClose}, "×"), children]);
  652. }
  653. SideView.styles = `
  654. /* Scrollbars in chrome since it doesn't support scrollbar-width */
  655. .${ns("SideView")}::-webkit-scrollbar {
  656. width: 10px;
  657. background-color: transparent;
  658. }
  659. .${ns("SideView")}::-webkit-scrollbar-track {
  660. border: 0;
  661. background-color: transparent;
  662. }
  663. .${ns("SideView")}::-webkit-scrollbar-thumb {
  664. border: 0;
  665. background-color: #6f6f70;
  666. }
  667.  
  668. .${ns("SideView")} {
  669. position: fixed;
  670. bottom: 0;
  671. left: 0;
  672. width: var(--media-list-width);
  673. height: calc(100vh - var(--media-list-height));
  674. padding: 1em 1.5em;
  675. color: #aaa;
  676. background: #161616;
  677. box-shadow: 0px 6px 0 3px #0003;
  678. overflow-x: hidden;
  679. overflow-y: auto;
  680. scrollbar-width: thin;
  681. }
  682. .${ns("SideView")} .${ns("close")} {
  683. position: sticky;
  684. top: 0;
  685. float: right;
  686. width: 1em;
  687. height: 1em;
  688. margin: 0 -.5em 0 0;
  689. padding: 0;
  690. background: transparent;
  691. border: 0;
  692. color: #eee;
  693. font-size: 2em !important;
  694. line-height: 1;
  695. }
  696. .${ns("SideView")} > *:last-child { padding-bottom: 1em; }
  697. .${ns("SideView")} fieldset {
  698. border: 0;
  699. margin: 1em 0;
  700. padding: 0;
  701. }
  702. .${ns("SideView")} fieldset + fieldset { margin-top: 2em; }
  703. .${ns("SideView")} fieldset > legend {
  704. margin: 0
  705. padding: 0;
  706. width: 100%;
  707. }
  708. .${ns("SideView")} fieldset > legend > .${ns("title")} {
  709. display: inline-block;
  710. font-size: 1.1em;
  711. color: #fff;
  712. min-width: 38%;
  713. text-align: right;
  714. font-weight: bold;
  715. vertical-align: middle;
  716. }
  717. .${ns("SideView")} fieldset > legend > .${ns("actions")} { display: inline-block; margin-left: 1em; }
  718. .${ns("SideView")} fieldset > legend > .${ns("actions")} > button { height: 2em; margin-right: .3em; }
  719. .${ns("SideView")} fieldset > article {
  720. display: flex;
  721. align-items: center;
  722. grid-gap: .5em 1em;
  723. }
  724. .${ns("SideView")} fieldset > * + article { margin-top: .8em; }
  725. .${ns("SideView")} fieldset > article > header {
  726. flex: 0 0 38%;
  727. text-align: right;
  728. color: #fff;
  729. }
  730. .${ns("SideView")} fieldset > article > section { flex: 1 1 0; }
  731.  
  732. .${ns("SideView")} fieldset.${ns("-value-heavy")} > article > header { flex: 0 0 20%; }
  733. .${ns("SideView")} fieldset.${ns("-compact")} > article { flex-wrap: wrap; }
  734. .${ns("SideView")} fieldset.${ns("-compact")} legend { text-align: left; }
  735. .${ns("SideView")} fieldset.${ns("-compact")} article > header {
  736. flex: 1 1 100%;
  737. margin-left: 1.5em;
  738. text-align: left;
  739. }
  740. .${ns("SideView")} fieldset.${ns("-compact")} article > section {
  741. flex: 1 1 100%;
  742. margin-left: 3em;
  743. }
  744. `;
  745.  
  746. // src/components/Settings.ts
  747. const {round} = Math;
  748. function Settings() {
  749. const settings11 = useSettings();
  750. const containerRef = useRef(null);
  751. const [containerWidth] = useElementSize(containerRef, "content-box", 100);
  752. function handleShortcutsKeyDown(event) {
  753. const target = event.target;
  754. if (!isOfType(target, target?.nodeName === "INPUT"))
  755. return;
  756. if (target.name.indexOf("key") !== 0)
  757. return;
  758. if (event.key === "Shift")
  759. return;
  760. event.preventDefault();
  761. event.stopPropagation();
  762. settings11[target.name] = keyEventId(event);
  763. }
  764. function handleShortcutsMouseDown(event) {
  765. if (event.button !== 0)
  766. return;
  767. const target = event.target;
  768. if (!isOfType(target, target?.nodeName === "BUTTON"))
  769. return;
  770. const name = target.name;
  771. if (!isOfType(name, name in settings11) || name.indexOf("key") !== 0)
  772. return;
  773. if (target.value === "unbind")
  774. settings11[name] = null;
  775. else if (target.value === "reset")
  776. settings11[name] = defaultSettings[name];
  777. }
  778. function shortcutsFieldset(title, shortcuts) {
  779. function all(action) {
  780. settings11._assign(shortcuts.reduce((acc, [name, title2, flag]) => {
  781. if ((action !== "unbind" || flag !== "required") && name.indexOf("key") === 0) {
  782. acc[name] = action === "reset" ? defaultSettings[name] : null;
  783. }
  784. return acc;
  785. }, {}));
  786. }
  787. return h("fieldset", {...compactPropsS, onKeyDown: handleShortcutsKeyDown, onMouseDown: handleShortcutsMouseDown}, [
  788. h("legend", null, [
  789. h("span", {class: ns("title")}, title),
  790. h("span", {class: ns("actions")}, [
  791. h("button", {class: ns("reset"), title: "Reset category", onClick: () => all("reset")}, "↻ reset"),
  792. h("button", {class: ns("unbind"), title: "Unbind category", onClick: () => all("unbind")}, "⦸ unbind")
  793. ])
  794. ]),
  795. shortcuts.map(([name, title2, flag]) => shortcutItem(name, title2, flag))
  796. ]);
  797. }
  798. function shortcutItem(name, title, flag) {
  799. const isDefault = settings11[name] === defaultSettings[name];
  800. return h("article", null, [
  801. h("header", null, title),
  802. h("section", null, [
  803. h("input", {
  804. type: "text",
  805. name,
  806. value: settings11[name] || "",
  807. placeholder: !settings11[name] ? "unbound" : void 0
  808. }),
  809. h("button", {
  810. class: ns("reset"),
  811. name,
  812. value: "reset",
  813. title: isDefault ? "Default value" : "Reset to default",
  814. disabled: isDefault
  815. }, isDefault ? "⦿" : "↻"),
  816. flag === "required" ? h("button", {class: ns("unbind"), title: `Required, can't unbind`, disabled: true}, "⚠") : settings11[name] !== null && h("button", {class: ns("unbind"), name, value: "unbind", title: "Unbind"}, "⦸")
  817. ])
  818. ]);
  819. }
  820. const compactPropsM = containerWidth && containerWidth < 450 ? {class: ns("-compact")} : null;
  821. const compactPropsS = containerWidth && containerWidth < 340 ? compactPropsM : null;
  822. return h("div", {class: ns("Settings"), ref: containerRef}, [
  823. h("h1", null, ["Settings "]),
  824. h("button", {class: ns("defaults"), onClick: settings11._reset, title: "Reset all settings to default values."}, "↻ defaults"),
  825. h("fieldset", compactPropsM, [
  826. h("article", null, [
  827. h("header", null, "Media list width × height"),
  828. h("section", null, h("code", null, [
  829. `${settings11.mediaListWidth}px × ${round(settings11.mediaListHeight * 100)}%`,
  830. " ",
  831. h("small", null, "(drag edges)")
  832. ]))
  833. ]),
  834. h("article", null, [
  835. h("header", null, "Items per row"),
  836. h("section", null, [
  837. h("input", {
  838. type: "range",
  839. min: 2,
  840. max: 6,
  841. step: 1,
  842. name: "mediaListItemsPerRow",
  843. value: settings11.mediaListItemsPerRow,
  844. onInput: withValue((value) => {
  845. const defaultValue = settings11._defaults.mediaListItemsPerRow;
  846. settings11.mediaListItemsPerRow = parseInt(value, 10) || defaultValue;
  847. })
  848. }),
  849. " ",
  850. h("code", null, settings11.mediaListItemsPerRow)
  851. ])
  852. ]),
  853. h("article", null, [
  854. h("header", null, "Thumbnail fit"),
  855. h("section", null, [
  856. h("label", null, [
  857. h("input", {
  858. type: "radio",
  859. name: "thumbnailFit",
  860. value: "contain",
  861. checked: settings11.thumbnailFit === "contain",
  862. onInput: () => settings11.thumbnailFit = "contain"
  863. }),
  864. " contain"
  865. ]),
  866. h("label", null, [
  867. h("input", {
  868. type: "radio",
  869. name: "thumbnailFit",
  870. value: "cover",
  871. checked: settings11.thumbnailFit === "cover",
  872. onInput: () => settings11.thumbnailFit = "cover"
  873. }),
  874. " cover"
  875. ])
  876. ])
  877. ])
  878. ]),
  879. h("fieldset", compactPropsM, [
  880. h("legend", null, h("span", {class: ns("title")}, "Full page mode")),
  881. h("article", null, [
  882. h("header", null, [
  883. "Activation ",
  884. h("small", {class: ns("-muted")}, [
  885. "key: ",
  886. h("kbd", {title: "Rebind below."}, `${settings11.keyViewFullPage}`)
  887. ])
  888. ]),
  889. h("section", null, [
  890. h("label", null, [
  891. h("input", {
  892. type: "radio",
  893. name: "fpmActivation",
  894. value: "hold",
  895. checked: settings11.fpmActivation === "hold",
  896. onInput: () => settings11.fpmActivation = "hold"
  897. }),
  898. " hold"
  899. ]),
  900. h("label", null, [
  901. h("input", {
  902. type: "radio",
  903. name: "fpmActivation",
  904. value: "toggle",
  905. checked: settings11.fpmActivation === "toggle",
  906. onInput: () => settings11.fpmActivation = "toggle"
  907. }),
  908. " toggle"
  909. ])
  910. ])
  911. ]),
  912. h("article", null, [
  913. h("header", {
  914. title: `Upscale only videos that cover less than ${round(round(settings11.fpmVideoUpscaleThreshold * 100))}% of the available dimensions (width<threshold and height<threshold).
  915. Set to 100% to always upscale if video is smaller than available area.
  916. Set to 0% to never upscale.`
  917. }, [h("span", {class: ns("help-indicator")}), " Video upscale threshold"]),
  918. h("section", null, [
  919. h("input", {
  920. type: "range",
  921. min: 0,
  922. max: 1,
  923. step: 0.05,
  924. name: "fpmVideoUpscaleThreshold",
  925. value: settings11.fpmVideoUpscaleThreshold,
  926. onInput: withValue((value) => settings11.fpmVideoUpscaleThreshold = parseFloat(value) || 0)
  927. }),
  928. " ",
  929. h("code", null, settings11.fpmVideoUpscaleThreshold === 0 ? "⦸" : `${round(settings11.fpmVideoUpscaleThreshold * 100)}%`)
  930. ])
  931. ]),
  932. h("article", null, [
  933. h("header", {
  934. title: `Don't upscale videos more than ${settings11.fpmVideoUpscaleLimit}x of their original size.`
  935. }, [h("span", {class: ns("help-indicator")}), " Video upscale limit"]),
  936. h("section", null, [
  937. h("input", {
  938. type: "range",
  939. min: 1,
  940. max: 10,
  941. step: 0.5,
  942. name: "fpmVideoUpscaleLimit",
  943. value: settings11.fpmVideoUpscaleLimit,
  944. onInput: withValue((value) => settings11.fpmVideoUpscaleLimit = parseInt(value, 10) || 0.025)
  945. }),
  946. " ",
  947. h("code", null, settings11.fpmVideoUpscaleLimit === 1 ? "⦸" : `${settings11.fpmVideoUpscaleLimit}x`)
  948. ])
  949. ]),
  950. h("article", null, [
  951. h("header", {
  952. class: ns("title"),
  953. title: `Upscale only images that cover less than ${round(round(settings11.fpmImageUpscaleThreshold * 100))}% of the available dimensions (width<threshold and height<threshold).
  954. Set to 100% to always upscale if image is smaller than available area.
  955. Set to 0% to never upscale.`
  956. }, [h("span", {class: ns("help-indicator")}), " Image upscale threshold"]),
  957. h("section", null, [
  958. h("input", {
  959. type: "range",
  960. min: 0,
  961. max: 1,
  962. step: 0.05,
  963. name: "fpmImageUpscaleThreshold",
  964. value: settings11.fpmImageUpscaleThreshold,
  965. onInput: withValue((value) => settings11.fpmImageUpscaleThreshold = parseFloat(value) || 0)
  966. }),
  967. " ",
  968. h("code", null, settings11.fpmImageUpscaleThreshold === 0 ? "⦸" : `${round(settings11.fpmImageUpscaleThreshold * 100)}%`)
  969. ])
  970. ]),
  971. h("article", null, [
  972. h("header", {
  973. title: `Don't upscale images more than ${settings11.fpmImageUpscaleLimit}x of their original size.`
  974. }, [h("span", {class: ns("help-indicator")}), " Image upscale limit"]),
  975. h("section", null, [
  976. h("input", {
  977. type: "range",
  978. min: 1,
  979. max: 10,
  980. step: 0.5,
  981. name: "fpmImageUpscaleLimit",
  982. value: settings11.fpmImageUpscaleLimit,
  983. onInput: withValue((value) => settings11.fpmImageUpscaleLimit = parseInt(value, 10) || 0.025)
  984. }),
  985. " ",
  986. h("code", null, settings11.fpmImageUpscaleLimit === 1 ? "⦸" : `${settings11.fpmImageUpscaleLimit}x`)
  987. ])
  988. ])
  989. ]),
  990. h("fieldset", compactPropsM, [
  991. h("legend", null, h("span", {class: ns("title")}, "Video player")),
  992. h("article", null, [
  993. h("header", null, "Volume"),
  994. h("section", null, [
  995. h("input", {
  996. type: "range",
  997. min: 0,
  998. max: 1,
  999. step: settings11.adjustVolumeBy,
  1000. name: "volume",
  1001. value: settings11.volume,
  1002. onInput: withValue((value) => settings11.volume = parseFloat(value) || 0.025)
  1003. }),
  1004. " ",
  1005. h("code", null, `${(settings11.volume * 100).toFixed(1)}%`)
  1006. ])
  1007. ]),
  1008. h("article", null, [
  1009. h("header", null, "Adjust volume by"),
  1010. h("section", null, [
  1011. h("input", {
  1012. type: "range",
  1013. min: 0.025,
  1014. max: 0.5,
  1015. step: 0.025,
  1016. name: "adjustVolumeBy",
  1017. value: settings11.adjustVolumeBy,
  1018. onInput: withValue((value) => settings11.adjustVolumeBy = parseFloat(value) || 0.025)
  1019. }),
  1020. " ",
  1021. h("code", null, `${(settings11.adjustVolumeBy * 100).toFixed(1)}%`)
  1022. ])
  1023. ]),
  1024. h("article", null, [
  1025. h("header", null, "Adjust speed by"),
  1026. h("section", null, [
  1027. h("input", {
  1028. type: "range",
  1029. min: 0.05,
  1030. max: 1,
  1031. step: 0.05,
  1032. name: "adjustSpeedBy",
  1033. value: settings11.adjustSpeedBy,
  1034. onInput: withValue((value) => settings11.adjustSpeedBy = parseFloat(value) || 0.025)
  1035. }),
  1036. " ",
  1037. h("code", null, `${(settings11.adjustSpeedBy * 100).toFixed(1)}%`)
  1038. ])
  1039. ]),
  1040. h("article", null, [
  1041. h("header", null, "Seek by"),
  1042. h("section", null, [
  1043. h("input", {
  1044. type: "range",
  1045. min: 1,
  1046. max: 60,
  1047. step: 1,
  1048. name: "seekBy",
  1049. value: settings11.seekBy,
  1050. onInput: withValue((value) => settings11.seekBy = parseInt(value, 10) || 0.025)
  1051. }),
  1052. " ",
  1053. h("code", null, `${settings11.seekBy} seconds`)
  1054. ])
  1055. ]),
  1056. h("article", null, [
  1057. h("header", null, "Tiny seek by"),
  1058. h("section", null, [
  1059. h("input", {
  1060. type: "range",
  1061. min: 1e-3,
  1062. max: 1,
  1063. step: 1e-3,
  1064. name: "tinySeekBy",
  1065. value: settings11.tinySeekBy,
  1066. onInput: withValue((value) => settings11.tinySeekBy = parseFloat(value) || 0.03)
  1067. }),
  1068. " ",
  1069. h("code", null, `${round(settings11.tinySeekBy * 1e3)} ms`)
  1070. ])
  1071. ]),
  1072. h("article", null, [
  1073. h("header", null, "End time format"),
  1074. h("section", null, [
  1075. h("label", null, [
  1076. h("input", {
  1077. type: "radio",
  1078. name: "endTimeFormat",
  1079. value: "total",
  1080. checked: settings11.endTimeFormat === "total",
  1081. onInput: () => settings11.endTimeFormat = "total"
  1082. }),
  1083. " total"
  1084. ]),
  1085. h("label", null, [
  1086. h("input", {
  1087. type: "radio",
  1088. name: "endTimeFormat",
  1089. value: "remaining",
  1090. checked: settings11.endTimeFormat === "remaining",
  1091. onInput: () => settings11.endTimeFormat = "remaining"
  1092. }),
  1093. " remaining"
  1094. ])
  1095. ])
  1096. ]),
  1097. h("article", null, [
  1098. h("header", null, [
  1099. "Fast forward activation",
  1100. " ",
  1101. h("small", {class: ns("-muted"), style: "white-space: nowrap"}, [
  1102. "key: ",
  1103. h("kbd", {title: "Rebind below."}, `${settings11.keyViewFastForward}`)
  1104. ])
  1105. ]),
  1106. h("section", null, [
  1107. h("label", null, [
  1108. h("input", {
  1109. type: "radio",
  1110. name: "fastForwardActivation",
  1111. value: "hold",
  1112. checked: settings11.fastForwardActivation === "hold",
  1113. onInput: () => settings11.fastForwardActivation = "hold"
  1114. }),
  1115. " hold"
  1116. ]),
  1117. h("label", null, [
  1118. h("input", {
  1119. type: "radio",
  1120. name: "fastForwardActivation",
  1121. value: "toggle",
  1122. checked: settings11.fastForwardActivation === "toggle",
  1123. onInput: () => settings11.fastForwardActivation = "toggle"
  1124. }),
  1125. " toggle"
  1126. ])
  1127. ])
  1128. ]),
  1129. h("article", null, [
  1130. h("header", null, "Fast forward rate"),
  1131. h("section", null, [
  1132. h("input", {
  1133. type: "range",
  1134. min: 1.5,
  1135. max: 10,
  1136. step: 0.5,
  1137. name: "fastForwardRate",
  1138. value: settings11.fastForwardRate,
  1139. onInput: withValue((value) => settings11.fastForwardRate = Math.max(1, parseFloat(value) || 2))
  1140. }),
  1141. " ",
  1142. h("code", null, `${settings11.fastForwardRate.toFixed(1)}x`)
  1143. ])
  1144. ])
  1145. ]),
  1146. h("fieldset", null, [
  1147. h("legend", null, h("span", {class: ns("title")}, "Catalog navigator")),
  1148. h("article", null, [
  1149. h("header", null, "Enabled"),
  1150. h("section", null, [
  1151. h("input", {
  1152. type: "checkbox",
  1153. name: "catalogNavigator",
  1154. value: "toggle",
  1155. checked: settings11.catalogNavigator,
  1156. onInput: (event) => settings11.catalogNavigator = event.target?.checked
  1157. })
  1158. ])
  1159. ])
  1160. ]),
  1161. shortcutsFieldset("Navigation shortcuts", [
  1162. ["keyToggleUI", "Toggle UI", "required"],
  1163. ["keyNavLeft", "Select left"],
  1164. ["keyNavRight", "Select right"],
  1165. ["keyNavUp", "Select up"],
  1166. ["keyNavDown", "Select down"],
  1167. ["keyNavPageBack", "Page back"],
  1168. ["keyNavPageForward", "Page forward"],
  1169. ["keyNavStart", "To start"],
  1170. ["keyNavEnd", "To end"]
  1171. ]),
  1172. shortcutsFieldset("Media list shortcuts", [
  1173. ["keyListViewToggle", "View selected"],
  1174. ["keyListViewLeft", "Select left & view"],
  1175. ["keyListViewRight", "Select right & view"],
  1176. ["keyListViewUp", "Select up & view"],
  1177. ["keyListViewDown", "Select down & view"]
  1178. ]),
  1179. shortcutsFieldset("Media view shortcuts", [
  1180. ["keyViewClose", "Close view"],
  1181. ["keyViewFullPage", "Full page mode"],
  1182. ["keyViewFullScreen", "Full screen mode"],
  1183. ["keyViewPause", "Pause"],
  1184. ["keyViewFastForward", "Fast forward"],
  1185. ["keyViewVolumeDown", "Volume down"],
  1186. ["keyViewVolumeUp", "Volume up"],
  1187. ["keyViewSpeedDown", "Speed down"],
  1188. ["keyViewSpeedUp", "Speed up"],
  1189. ["keyViewSpeedReset", "Speed reset"],
  1190. ["keyViewSeekBack", "Seek back"],
  1191. ["keyViewSeekForward", "Seek forward"],
  1192. ["keyViewTinySeekBack", "Tiny seek back"],
  1193. ["keyViewTinySeekForward", "Tiny seek forward"],
  1194. ["keyViewSeekTo0", "Seek to 0%"],
  1195. ["keyViewSeekTo10", "Seek to 10%"],
  1196. ["keyViewSeekTo20", "Seek to 20%"],
  1197. ["keyViewSeekTo30", "Seek to 30%"],
  1198. ["keyViewSeekTo40", "Seek to 40%"],
  1199. ["keyViewSeekTo50", "Seek to 50%"],
  1200. ["keyViewSeekTo60", "Seek to 60%"],
  1201. ["keyViewSeekTo70", "Seek to 70%"],
  1202. ["keyViewSeekTo80", "Seek to 80%"],
  1203. ["keyViewSeekTo90", "Seek to 90%"]
  1204. ]),
  1205. shortcutsFieldset("Catalog shortcuts", [
  1206. ["keyCatalogOpenThread", "Open"],
  1207. ["keyCatalogOpenThreadInNewTab", "Open in new tab"],
  1208. ["keyCatalogOpenThreadInBackgroundTab", "Open in background tab"]
  1209. ])
  1210. ]);
  1211. }
  1212. Settings.styles = `
  1213. .${ns("Settings")} .${ns("defaults")} {
  1214. position: absolute;
  1215. top: 1em; right: 4em;
  1216. height: 2em;
  1217. }
  1218. .${ns("Settings")} label {
  1219. margin-right: .5em;
  1220. background: #fff1;
  1221. padding: .3em;
  1222. border-radius: 2px;
  1223. }
  1224. .${ns("Settings")} input::placeholder {
  1225. font-style: italic;
  1226. color: #000a;
  1227. font-size: .9em;
  1228. }
  1229. .${ns("Settings")} button.${ns("reset")}:not(:disabled):hover {
  1230. color: #fff;
  1231. border-color: #1196bf;
  1232. background: #1196bf;
  1233. }
  1234. .${ns("Settings")} button.${ns("unbind")}:not(:disabled):hover {
  1235. color: #fff;
  1236. border-color: #f44;
  1237. background: #f44;
  1238. }
  1239. .${ns("Settings")} article button.${ns("reset")},
  1240. .${ns("Settings")} article button.${ns("unbind")} { margin-left: 0.3em; }
  1241. `;
  1242.  
  1243. // src/components/Help.ts
  1244. function Help() {
  1245. const s = useSettings();
  1246. return h("div", {class: ns("Help")}, [
  1247. h("h1", null, "Help"),
  1248. h("fieldset", {class: ns("-value-heavy")}, [
  1249. h("article", null, [
  1250. h("header", null, "Registry"),
  1251. h("section", null, h("a", {href: "https://greasyfork.org/en/scripts/408038-thread-media-viewer"}, "greasyfork.org/en/scripts/408038"))
  1252. ]),
  1253. h("article", null, [
  1254. h("header", null, "Repository"),
  1255. h("section", null, h("a", {href: "https://github.com/qimasho/thread-media-viewer"}, "github.com/qimasho/thread-media-viewer"))
  1256. ]),
  1257. h("article", null, [
  1258. h("header", null, "Issues"),
  1259. h("section", null, h("a", {href: "https://github.com/qimasho/thread-media-viewer/issues"}, "github.com/qimasho/thread-media-viewer/issues"))
  1260. ])
  1261. ]),
  1262. h("h2", null, "Mouse controls"),
  1263. h("ul", {class: ns("-clean")}, [
  1264. h("li", null, ["Right button gesture ", h("kbd", null, "↑"), " to toggle media list."]),
  1265. h("li", null, ["Right button gesture ", h("kbd", null, "↓"), " to close media view."]),
  1266. h("li", null, [h("kbd", null, "click"), " on thumbnail (thread or list) to open media viewer."]),
  1267. h("li", null, [
  1268. h("kbd", null, "click"),
  1269. " on text portion of thumbnail (thread media list) or thread title/snippet (catalog) to move cursor to that item."
  1270. ]),
  1271. h("li", null, [h("kbd", null, "shift+click"), " on thumbnail (thread) to open both media view and list."]),
  1272. h("li", null, [h("kbd", null, "double-click"), " to toggle fullscreen."]),
  1273. h("li", null, [h("kbd", null, "mouse wheel"), " on video to change audio volume."]),
  1274. h("li", null, [h("kbd", null, "mouse wheel"), " on timeline to seek video."]),
  1275. h("li", null, [h("kbd", null, "mouse down"), " on image for 1:1 zoom and pan."])
  1276. ]),
  1277. h("h2", null, "FAQ"),
  1278. h("dl", null, [
  1279. h("dt", null, "Why does the page scroll when I'm navigating items?"),
  1280. h("dd", null, "It scrolls to place the associated post right below the media list box."),
  1281. h("dt", null, "What are the small squares at the bottom of thumbnails?"),
  1282. h("dd", null, "Visualization of the number of replies the post has.")
  1283. ])
  1284. ]);
  1285. }
  1286.  
  1287. // src/components/Changelog.ts
  1288. const TITLE = (version, date) => h("h2", null, h("code", null, [version, h("span", {class: ns("-muted")}, " ⬩ "), h("small", null, date)]));
  1289. function Changelog() {
  1290. const settings11 = useSettings();
  1291. if (settings11.lastAcknowledgedVersion !== defaultSettings.lastAcknowledgedVersion) {
  1292. settings11.lastAcknowledgedVersion = defaultSettings.lastAcknowledgedVersion;
  1293. }
  1294. return h("div", {class: ns("Changelog")}, [
  1295. h("h1", null, "Changelog"),
  1296. TITLE("2.2.0", "2020.09.15"),
  1297. h("ul", null, [
  1298. h("li", null, "Added shortcuts to adjust video speed and setting for the adjustment amount."),
  1299. h("li", null, `Added shortcuts for tiny video seeking by configurable amount of milliseconds. This is a poor man's frame step in an environment where we don't know video framerate.`)
  1300. ]),
  1301. TITLE("2.1.2", "2020.09.14"),
  1302. h("ul", null, [
  1303. h("li", null, "Style tweaks."),
  1304. h("li", null, "Added an option to click on the text portion of the thumbnail (media list) or thread title/snippet (catalog) to move cursor to that item.")
  1305. ]),
  1306. TITLE("2.1.1", "2020.09.13"),
  1307. h("ul", null, [
  1308. h("li", null, 'Added "Thumbnail fit" setting.'),
  1309. h("li", null, "Catalog cursor now pre-selects the item that is closest to the center of the screen instead of always the 1st one."),
  1310. h("li", null, "Added new version indicator (changelog button turns green until clicked)"),
  1311. h("li", null, "Fixed video pausing when clicked with other then primary mouse buttons.")
  1312. ]),
  1313. TITLE("2.0.0", "2020.09.12"),
  1314. h("ul", null, [
  1315. 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"), ")."),
  1316. h("li", null, "Added catalog navigation to use same shortcuts to browse and open threads in catalogs."),
  1317. h("li", null, "Added settings with knobs for pretty much everything."),
  1318. h("li", null, "Added changelog (hi)."),
  1319. 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).`)
  1320. ])
  1321. ]);
  1322. }
  1323.  
  1324. // src/components/SideNav.ts
  1325. function SideNav({active, onActive}) {
  1326. const settings11 = useSettings();
  1327. const isNewVersion = settings11.lastAcknowledgedVersion !== defaultSettings.lastAcknowledgedVersion;
  1328. function button(name, title, className) {
  1329. let classNames = "";
  1330. if (active === name)
  1331. classNames += ` ${ns("-active")}`;
  1332. if (className)
  1333. classNames += ` ${className}`;
  1334. return h("button", {class: classNames, onClick: () => onActive(name)}, title);
  1335. }
  1336. return h("div", {class: ns("SideNav")}, [
  1337. button("settings", "⚙ settings"),
  1338. button("help", "? help"),
  1339. button("changelog", "☲ changelog", isNewVersion ? ns("-success") : void 0)
  1340. ]);
  1341. }
  1342. SideNav.styles = `
  1343. .${ns("SideNav")} { display: flex; min-width: 0; }
  1344. .${ns("SideNav")} > button,
  1345. .${ns("SideNav")} > button:active {
  1346. color: #eee;
  1347. background: #1c1c1c;
  1348. border: 0;
  1349. outline: 0;
  1350. border-radius: 2px;
  1351. font-size: .911em;
  1352. line-height: 1;
  1353. height: 20px;
  1354. padding: 0 .5em;
  1355. white-space: nowrap;
  1356. overflow: hidden;
  1357. }
  1358. .${ns("SideNav")} > button:hover {
  1359. color: #fff;
  1360. background: #333;
  1361. }
  1362. .${ns("SideNav")} > button + button {
  1363. margin-left: 2px;
  1364. }
  1365. .${ns("SideNav")} > button.${ns("-active")} {
  1366. color: #222;
  1367. background: #ccc;
  1368. }
  1369. .${ns("SideNav")} > button.${ns("-success")} {
  1370. color: #fff;
  1371. background: #4b663f;
  1372. }
  1373. .${ns("SideNav")} > button.${ns("-success")}:hover {
  1374. background: #b6eaa0;
  1375. }
  1376. `;
  1377.  
  1378. // src/components/MediaList.ts
  1379. const {max, min, round: round2} = Math;
  1380. function MediaList({
  1381. media,
  1382. activeIndex,
  1383. sideView,
  1384. onActivation,
  1385. onOpenSideView
  1386. }) {
  1387. const settings11 = useSettings();
  1388. const containerRef = useRef(null);
  1389. const listRef = useRef(null);
  1390. let [selectedIndex, setSelectedIndex] = useState(activeIndex);
  1391. const [isDragged, setIsDragged] = useState(false);
  1392. const itemsPerRow = settings11.mediaListItemsPerRow;
  1393. if (selectedIndex == null) {
  1394. const centerOffset = window.innerHeight / 2;
  1395. let lastProximity = Infinity;
  1396. for (let i = 0; i < media.length; i++) {
  1397. const rect = media[i].postContainer.getBoundingClientRect();
  1398. let proximity = Math.abs(centerOffset - rect.top);
  1399. if (rect.top > centerOffset) {
  1400. selectedIndex = lastProximity < proximity ? i - 1 : i;
  1401. break;
  1402. }
  1403. lastProximity = proximity;
  1404. }
  1405. if (selectedIndex == null && media.length > 0)
  1406. selectedIndex = media.length - 1;
  1407. if (selectedIndex != null && selectedIndex >= 0)
  1408. setSelectedIndex(selectedIndex);
  1409. }
  1410. function scrollToItem(index, behavior = "smooth") {
  1411. const targetChild = listRef.current?.children[index];
  1412. if (isOfType(targetChild, targetChild != null)) {
  1413. scrollToView(targetChild, {block: "center", behavior});
  1414. }
  1415. }
  1416. function selectAndScrollTo(index) {
  1417. if (media.length > 0 && index >= 0 && index < media.length) {
  1418. setSelectedIndex(index);
  1419. scrollToItem(index);
  1420. }
  1421. }
  1422. function initiateResize(event) {
  1423. const target = event.target;
  1424. const direction = target?.dataset.direction;
  1425. if (event.detail === 2 || event.button !== 0 || !direction)
  1426. return;
  1427. event.preventDefault();
  1428. event.stopPropagation();
  1429. const initialDocumentCursor = document.documentElement.style.cursor;
  1430. const resizeX = direction === "ew" || direction === "nwse";
  1431. const resizeY = direction === "ns" || direction === "nwse";
  1432. const initialCursorToRightEdgeDelta = containerRef.current ? event.clientX - containerRef.current.offsetWidth : 0;
  1433. function handleMouseMove(event2) {
  1434. const clampedListWidth = clamp(300, event2.clientX - initialCursorToRightEdgeDelta, window.innerWidth - 300);
  1435. if (resizeX)
  1436. settings11.mediaListWidth = clampedListWidth;
  1437. const clampedListHeight = clamp(200 / window.innerHeight, event2.clientY / window.innerHeight, 1 - 200 / window.innerHeight);
  1438. if (resizeY)
  1439. settings11.mediaListHeight = clampedListHeight;
  1440. }
  1441. function handleMouseUp() {
  1442. settings11.mediaListWidth = round2(settings11.mediaListWidth / 10) * 10;
  1443. window.removeEventListener("mouseup", handleMouseUp);
  1444. window.removeEventListener("mousemove", handleMouseMove);
  1445. document.documentElement.style.cursor = initialDocumentCursor;
  1446. setIsDragged(false);
  1447. }
  1448. document.documentElement.style.cursor = `${direction}-resize`;
  1449. setIsDragged(true);
  1450. window.addEventListener("mouseup", handleMouseUp);
  1451. window.addEventListener("mousemove", handleMouseMove);
  1452. }
  1453. useEffect(() => {
  1454. if (activeIndex != null && activeIndex != selectedIndex)
  1455. selectAndScrollTo(activeIndex);
  1456. }, [activeIndex]);
  1457. useEffect(() => {
  1458. if (selectedIndex != null)
  1459. scrollToItem(selectedIndex, "auto");
  1460. }, []);
  1461. useEffect(() => {
  1462. if (selectedIndex != null && media?.[selectedIndex]?.postContainer && containerRef.current) {
  1463. let offset = getBoundingDocumentRect(containerRef.current).height;
  1464. scrollToView(media[selectedIndex].postContainer, {block: round2(offset), behavior: "smooth"});
  1465. }
  1466. }, [selectedIndex]);
  1467. const selectUp = () => selectedIndex != null && selectAndScrollTo(max(selectedIndex - itemsPerRow, 0));
  1468. const selectDown = () => {
  1469. if (selectedIndex == media.length - 1) {
  1470. document.scrollingElement?.scrollTo({
  1471. top: document.scrollingElement.scrollHeight,
  1472. behavior: "smooth"
  1473. });
  1474. }
  1475. if (selectedIndex != null)
  1476. selectAndScrollTo(min(selectedIndex + itemsPerRow, media.length - 1));
  1477. };
  1478. const selectPrev = () => selectedIndex != null && selectAndScrollTo(max(selectedIndex - 1, 0));
  1479. const selectNext = () => selectedIndex != null && selectAndScrollTo(min(selectedIndex + 1, media.length - 1));
  1480. const selectPageBack = () => selectedIndex != null && selectAndScrollTo(max(selectedIndex - itemsPerRow * 3, 0));
  1481. const selectPageForward = () => selectedIndex != null && selectAndScrollTo(min(selectedIndex + itemsPerRow * 3, media.length));
  1482. const selectFirst = () => selectAndScrollTo(0);
  1483. const selectLast = () => selectAndScrollTo(media.length - 1);
  1484. const selectAndViewPrev = () => {
  1485. if (selectedIndex != null) {
  1486. const prevIndex = max(selectedIndex - 1, 0);
  1487. selectAndScrollTo(prevIndex);
  1488. onActivation(prevIndex);
  1489. }
  1490. };
  1491. const selectAndViewNext = () => {
  1492. if (selectedIndex != null) {
  1493. const nextIndex = min(selectedIndex + 1, media.length - 1);
  1494. selectAndScrollTo(nextIndex);
  1495. onActivation(nextIndex);
  1496. }
  1497. };
  1498. const selectAndViewUp = () => {
  1499. if (selectedIndex != null) {
  1500. const index = max(selectedIndex - itemsPerRow, 0);
  1501. selectAndScrollTo(index);
  1502. onActivation(index);
  1503. }
  1504. };
  1505. const selectAndViewDown = () => {
  1506. if (selectedIndex != null) {
  1507. const index = min(selectedIndex + itemsPerRow, media.length - 1);
  1508. selectAndScrollTo(index);
  1509. onActivation(index);
  1510. }
  1511. };
  1512. const toggleViewSelectedItem = () => onActivation(selectedIndex === activeIndex ? null : selectedIndex);
  1513. useKey(settings11.keyNavLeft, selectPrev);
  1514. useKey(settings11.keyNavRight, selectNext);
  1515. useKey(settings11.keyNavUp, selectUp);
  1516. useKey(settings11.keyNavDown, selectDown);
  1517. useKey(settings11.keyListViewUp, selectAndViewUp);
  1518. useKey(settings11.keyListViewDown, selectAndViewDown);
  1519. useKey(settings11.keyListViewLeft, selectAndViewPrev);
  1520. useKey(settings11.keyListViewRight, selectAndViewNext);
  1521. useKey(settings11.keyListViewToggle, toggleViewSelectedItem);
  1522. useKey(settings11.keyNavPageBack, selectPageBack);
  1523. useKey(settings11.keyNavPageForward, selectPageForward);
  1524. useKey(settings11.keyNavStart, selectFirst);
  1525. useKey(settings11.keyNavEnd, selectLast);
  1526. function mediaItem({url, thumbnailUrl, extension, isVideo, isGif, replies, size, width, height}, index) {
  1527. let classNames2 = ns("item");
  1528. if (selectedIndex === index)
  1529. classNames2 += ` ${ns("-selected")}`;
  1530. if (activeIndex === index)
  1531. classNames2 += ` ${ns("-active")}`;
  1532. function onClick(event) {
  1533. event.preventDefault();
  1534. setSelectedIndex(index);
  1535. onActivation(index);
  1536. }
  1537. let metaStr = size;
  1538. if (width && height) {
  1539. const widthAndHeight = `${width${height}`;
  1540. metaStr = size ? `${size}, ${widthAndHeight}` : widthAndHeight;
  1541. }
  1542. return h("div", {key: url, class: classNames2}, [
  1543. h("a", {href: url, onClick}, h("img", {src: thumbnailUrl})),
  1544. metaStr && h("span", {class: ns("meta"), onClick: () => setSelectedIndex(index)}, metaStr),
  1545. (isVideo || isGif) && h("span", {class: ns("video-type")}, null, extension),
  1546. replies != null && replies > 0 && h("span", {class: ns("replies")}, null, Array(replies).fill(h("span", null)))
  1547. ]);
  1548. }
  1549. let classNames = ns("MediaList");
  1550. if (settings11.thumbnailFit === "cover")
  1551. classNames += ` ${ns("-thumbnail-fit-cover")}`;
  1552. return h("div", {class: classNames, ref: containerRef}, [
  1553. h("div", {class: ns("list"), ref: listRef}, media.map(mediaItem)),
  1554. h("div", {class: ns("status-bar")}, [
  1555. h(SideNav, {active: sideView, onActive: onOpenSideView}),
  1556. h("div", {class: ns("position")}, [
  1557. h("span", {class: ns("current")}, selectedIndex ? selectedIndex + 1 : 0),
  1558. h("span", {class: ns("separator")}, "/"),
  1559. h("span", {class: ns("total")}, media.length)
  1560. ])
  1561. ]),
  1562. !isDragged && h("div", {class: ns("dragger-x"), ["data-direction"]: "ew", onMouseDown: initiateResize}),
  1563. !isDragged && h("div", {class: ns("dragger-y"), ["data-direction"]: "ns", onMouseDown: initiateResize}),
  1564. !isDragged && h("div", {class: ns("dragger-xy"), ["data-direction"]: "nwse", onMouseDown: initiateResize})
  1565. ]);
  1566. }
  1567. MediaList.styles = `
  1568. /* Scrollbars in chrome since it doesn't support scrollbar-width */
  1569. .${ns("MediaList")} > .${ns("list")}::-webkit-scrollbar {
  1570. width: 10px;
  1571. background-color: transparent;
  1572. }
  1573. .${ns("MediaList")} > .${ns("list")}::-webkit-scrollbar-track {
  1574. border: 0;
  1575. background-color: transparent;6F6F70
  1576. }
  1577. .${ns("MediaList")} > .${ns("list")}::-webkit-scrollbar-thumb {
  1578. border: 0;
  1579. background-color: #6f6f70;
  1580. }
  1581.  
  1582. .${ns("MediaList")} {
  1583. --item-border-size: 2px;
  1584. --item-meta-height: 18px;
  1585. --list-meta-height: 24px;
  1586. --active-color: #fff;
  1587. --thumbnail-fit: contain;
  1588. position: absolute;
  1589. top: 0;
  1590. left: 0;
  1591. display: grid;
  1592. grid-template-columns: 1fr;
  1593. grid-template-rows: 1fr var(--list-meta-height);
  1594. width: var(--media-list-width);
  1595. height: var(--media-list-height);
  1596. background: #111;
  1597. box-shadow: 0px 0px 0 3px #0003;
  1598. }
  1599. .${ns("MediaList")}.${ns("-thumbnail-fit-cover")} { --thumbnail-fit: cover; }
  1600. .${ns("MediaList")} > .${ns("dragger-x")} {
  1601. position: absolute;
  1602. left: 100%; top: 0;
  1603. width: 12px; height: 100%;
  1604. cursor: ew-resize;
  1605. z-index: 2;
  1606. }
  1607. .${ns("MediaList")} > .${ns("dragger-y")} {
  1608. position: absolute;
  1609. top: 100%; left: 0;
  1610. width: 100%; height: 12px;
  1611. cursor: ns-resize;
  1612. z-index: 2;
  1613. }
  1614. .${ns("MediaList")} > .${ns("dragger-xy")} {
  1615. position: absolute;
  1616. bottom: -10px; right: -10px;
  1617. width: 20px; height: 20px;
  1618. cursor: nwse-resize;
  1619. z-index: 2;
  1620. }
  1621. .${ns("MediaList")} > .${ns("list")} {
  1622. display: grid;
  1623. grid-template-columns: repeat(var(--media-list-items-per-row), 1fr);
  1624. grid-auto-rows: var(--media-list-item-height);
  1625. overflow-y: scroll;
  1626. overflow-x: hidden;
  1627. scrollbar-width: thin;
  1628. }
  1629. .${ns("MediaList")} > .${ns("list")} > .${ns("item")} {
  1630. position: relative;
  1631. background: none;
  1632. border: var(--item-border-size) solid transparent;
  1633. padding: 0;
  1634. background-color: #222;
  1635. background-clip: padding-box;
  1636. outline: none;
  1637. }
  1638. .${ns("MediaList")} > .${ns("list")} > .${ns("item")}.${ns("-selected")} {
  1639. border-color: var(--active-color);
  1640. }
  1641. .${ns("MediaList")} > .${ns("list")} > .${ns("item")}.${ns("-active")} {
  1642. background-color: var(--active-color);
  1643. }
  1644. .${ns("MediaList")} > .${ns("list")} > .${ns("item")}.${ns("-selected")}:after {
  1645. content: '';
  1646. display: block;
  1647. position: absolute;
  1648. top: 0; left: 0;
  1649. width: 100%;
  1650. height: 100%;
  1651. border: 2px solid #222a;
  1652. pointer-events: none;
  1653. }
  1654. .${ns("MediaList")} > .${ns("list")} > .${ns("item")} img {
  1655. display: block;
  1656. width: 100%;
  1657. height: calc(var(--media-list-item-height) - var(--item-meta-height) - (var(--item-border-size) * 2));
  1658. background-clip: padding-box;
  1659. object-fit: var(--thumbnail-fit);
  1660. }
  1661. .${ns("MediaList")} > .${ns("list")} > .${ns("item")}.${ns("-active")} img {
  1662. border: 1px solid transparent;
  1663. border-bottom: 0;
  1664. }
  1665. .${ns("MediaList")} > .${ns("list")} > .${ns("item")} > .${ns("meta")} {
  1666. position: absolute;
  1667. bottom: 0;
  1668. left: 0;
  1669. width: 100%;
  1670. height: var(--item-meta-height);
  1671. display: flex;
  1672. align-items: center;
  1673. justify-content: center;
  1674. color: #fff;
  1675. font-size: calc(var(--item-meta-height) * 0.71);
  1676. line-height: 1;
  1677. background: #0003;
  1678. text-shadow: 1px 1px #0003, -1px -1px #0003, 1px -1px #0003, -1px 1px #0003,
  1679. 0px 1px #0003, 0px -1px #0003, 1px 0px #0003, -1px 0px #0003;
  1680. white-space: nowrap;
  1681. overflow: hidden;
  1682. }
  1683. .${ns("MediaList")} > .${ns("list")} > .${ns("item")}.${ns("-active")} > .${ns("meta")} {
  1684. color: #222;
  1685. text-shadow: none;
  1686. background: #0001;
  1687. }
  1688. .${ns("MediaList")} > .${ns("list")} > .${ns("item")} > .${ns("video-type")} {
  1689. display: block;
  1690. position: absolute;
  1691. top: 50%;
  1692. left: 50%;
  1693. transform: translate(-50%, -50%);
  1694. padding: .5em .5em;
  1695. font-size: 12px !important;
  1696. text-transform: uppercase;
  1697. font-weight: bold;
  1698. line-height: 1;
  1699. color: #222;
  1700. background: #eeeeee88;
  1701. border-radius: 2px;
  1702. border: 1px solid #0000002e;
  1703. background-clip: padding-box;
  1704. pointer-events: none;
  1705. }
  1706. .${ns("MediaList")} > .${ns("list")} > .${ns("item")} > .${ns("replies")} {
  1707. display: block;
  1708. position: absolute;
  1709. bottom: calc(var(--item-meta-height) + 2px);
  1710. left: 0;
  1711. width: 100%;
  1712. display: flex;
  1713. justify-content: center;
  1714. flex-wrap: wrap-reverse;
  1715. }
  1716. .${ns("MediaList")} > .${ns("list")} > .${ns("item")} > .${ns("replies")} > span {
  1717. display: block;
  1718. width: 6px;
  1719. height: 6px;
  1720. margin: 1px;
  1721. background: var(--active-color);
  1722. background-clip: padding-box;
  1723. border: 1px solid #0008;
  1724. }
  1725. .${ns("MediaList")} > .${ns("status-bar")} {
  1726. display: grid;
  1727. grid-template-columns: 1fr auto;
  1728. grid-template-rows: 1fr;
  1729. margin: 0 2px;
  1730. font-size: calc(var(--list-meta-height) * 0.64);
  1731. }
  1732. .${ns("MediaList")} > .${ns("status-bar")} > * {
  1733. display: flex;
  1734. align-items: center;
  1735. }
  1736. .${ns("MediaList")} > .${ns("status-bar")} > .${ns("position")} {
  1737. margin: 0 .4em;
  1738. }
  1739. .${ns("MediaList")} > .${ns("status-bar")} > .${ns("position")} > .${ns("current")} {
  1740. font-weight: bold;
  1741. }
  1742. .${ns("MediaList")} > .${ns("status-bar")} > .${ns("position")} > .${ns("separator")} {
  1743. font-size: 1.05em;
  1744. margin: 0 0.15em;
  1745. }
  1746. `;
  1747.  
  1748. // src/components/ErrorBox.ts
  1749. function ErrorBox({error, message}) {
  1750. const code = error?.code;
  1751. const msg = error?.message || message;
  1752. return h("div", {class: ns("ErrorBox")}, [
  1753. code != null && h("h1", null, `Error code: ${code}`),
  1754. h("pre", null, h("code", null, `${msg ?? "Unknown error"}`))
  1755. ]);
  1756. }
  1757. ErrorBox.styles = `
  1758. .${ns("ErrorBox")} {
  1759. display: flex;
  1760. flex-direction: column;
  1761. align-items: center;
  1762. justify-content: center;
  1763. padding: 2em 2.5em;
  1764. background: #a34;
  1765. color: #fff;
  1766. }
  1767. .${ns("ErrorBox")} > h1 { font-size: 1.2em; margin: 0 0 1em; }
  1768. .${ns("ErrorBox")} > pre { margin: 0; }
  1769. `;
  1770.  
  1771. // src/components/Spinner.ts
  1772. function Spinner() {
  1773. return h("div", {class: ns("Spinner")});
  1774. }
  1775. Spinner.styles = `
  1776. .${ns("Spinner")} {
  1777. width: 1.6em;
  1778. height: 1.6em;
  1779. }
  1780. .${ns("Spinner")}::after {
  1781. content: '';
  1782. display: block;
  1783. width: 100%;
  1784. height: 100%;
  1785. animation: Spinner-rotate 500ms infinite linear;
  1786. border: 0.1em solid #fffa;
  1787. border-right-color: #1d1f21aa;
  1788. border-left-color: #1d1f21aa;
  1789. border-radius: 50%;
  1790. }
  1791.  
  1792. @keyframes Spinner-rotate {
  1793. 0% { transform: rotate(0deg); }
  1794. 100% { transform: rotate(360deg); }
  1795. }
  1796. `;
  1797.  
  1798. // src/components/MediaImage.ts
  1799. const {min: min2, max: max2, round: round3} = Math;
  1800. function MediaImage({
  1801. url,
  1802. upscale = false,
  1803. upscaleThreshold = 0,
  1804. upscaleLimit = 2
  1805. }) {
  1806. const containerRef = useRef(null);
  1807. const imageRef = useRef(null);
  1808. const [isLoading, setIsLoading] = useState(true);
  1809. const [error, setError] = useState(null);
  1810. const [zoomPan, setZoomPan] = useState(false);
  1811. const [containerWidth, containerHeight] = useElementSize(containerRef);
  1812. useLayoutEffect(() => {
  1813. const image = imageRef.current;
  1814. if (error || !image)
  1815. return;
  1816. let checkId = null;
  1817. const check = () => {
  1818. if (image.naturalWidth > 0)
  1819. setIsLoading(false);
  1820. else
  1821. checkId = setTimeout(check, 50);
  1822. };
  1823. setError(null);
  1824. setIsLoading(true);
  1825. check();
  1826. return () => checkId != null && clearTimeout(checkId);
  1827. }, [url, error]);
  1828. useLayoutEffect(() => {
  1829. const image = imageRef.current;
  1830. if (!upscale || isLoading || !image || !containerWidth || !containerHeight)
  1831. return;
  1832. const naturalWidth = image.naturalWidth;
  1833. const naturalHeight = image.naturalHeight;
  1834. if (naturalWidth < containerWidth * upscaleThreshold && naturalHeight < containerHeight * upscaleThreshold) {
  1835. const windowAspectRatio = containerWidth / containerHeight;
  1836. const videoAspectRatio = naturalWidth / naturalHeight;
  1837. let newHeight, newWidth;
  1838. if (windowAspectRatio > videoAspectRatio) {
  1839. newHeight = min2(naturalHeight * upscaleLimit, containerHeight);
  1840. newWidth = round3(naturalWidth * (newHeight / naturalHeight));
  1841. } else {
  1842. newWidth = min2(naturalWidth * upscaleLimit, containerWidth);
  1843. newHeight = round3(naturalHeight * (newWidth / naturalWidth));
  1844. }
  1845. image.setAttribute("width", `${newWidth}`);
  1846. image.setAttribute("height", `${newHeight}`);
  1847. }
  1848. return () => {
  1849. image.removeAttribute("width");
  1850. image.removeAttribute("height");
  1851. };
  1852. }, [isLoading, url, upscale, upscaleThreshold, upscaleLimit, containerWidth, containerHeight]);
  1853. useLayoutEffect(() => {
  1854. const container = containerRef.current;
  1855. const image = imageRef.current;
  1856. if (!zoomPan || !image || !container)
  1857. return;
  1858. const zoomMargin = 10;
  1859. const previewRect = image.getBoundingClientRect();
  1860. const zoomFactor = image.naturalWidth / previewRect.width;
  1861. const cursorAnchorX = previewRect.left + previewRect.width / 2;
  1862. const cursorAnchorY = previewRect.top + previewRect.height / 2;
  1863. const availableWidth = container.clientWidth;
  1864. const availableHeight = container.clientHeight;
  1865. const dragWidth = max2((previewRect.width - availableWidth / zoomFactor) / 2, 0);
  1866. const dragHeight = max2((previewRect.height - availableHeight / zoomFactor) / 2, 0);
  1867. const translateWidth = max2((image.naturalWidth - availableWidth) / 2, 0);
  1868. const translateHeight = max2((image.naturalHeight - availableHeight) / 2, 0);
  1869. Object.assign(image.style, {
  1870. maxWidth: "none",
  1871. maxHeight: "none",
  1872. width: "auto",
  1873. height: "auto",
  1874. position: "fixed",
  1875. top: "50%",
  1876. left: "50%"
  1877. });
  1878. const panTo = (x, y) => {
  1879. const dragFactorX = dragWidth > 0 ? -((x - cursorAnchorX) / dragWidth) : 0;
  1880. const dragFactorY = dragHeight > 0 ? -((y - cursorAnchorY) / dragHeight) : 0;
  1881. const left = round3(min2(max2(dragFactorX * translateWidth, -translateWidth - zoomMargin), translateWidth + zoomMargin));
  1882. const top = round3(min2(max2(dragFactorY * translateHeight, -translateHeight - zoomMargin), translateHeight + zoomMargin));
  1883. image.style.transform = `translate(-50%, -50%) translate(${left}px, ${top}px)`;
  1884. };
  1885. const handleMouseMove = (event) => {
  1886. event.preventDefault();
  1887. event.stopPropagation();
  1888. panTo(event.clientX, event.clientY);
  1889. };
  1890. const handleMouseUp = () => {
  1891. image.style.cssText = "";
  1892. window.removeEventListener("mouseup", handleMouseUp);
  1893. window.removeEventListener("mousemove", handleMouseMove);
  1894. setZoomPan(false);
  1895. };
  1896. panTo(zoomPan.initialX, zoomPan.initialY);
  1897. window.addEventListener("mousemove", handleMouseMove);
  1898. window.addEventListener("mouseup", handleMouseUp);
  1899. }, [zoomPan]);
  1900. function handleMouseDown(event) {
  1901. if (event.button !== 0)
  1902. return;
  1903. event.preventDefault();
  1904. setZoomPan({initialX: event.clientX, initialY: event.clientY});
  1905. }
  1906. if (error)
  1907. return h(ErrorBox, {error});
  1908. let classNames = ns("MediaImage");
  1909. if (isLoading)
  1910. classNames += ` ${ns("-loading")}`;
  1911. if (zoomPan)
  1912. classNames += ` ${ns("-zoom-pan")}`;
  1913. return h("div", {class: classNames, ref: containerRef}, isLoading && h(Spinner, null), h("img", {
  1914. ref: imageRef,
  1915. onMouseDown: handleMouseDown,
  1916. onError: () => setError(new Error("Image failed to load")),
  1917. src: url
  1918. }));
  1919. }
  1920. MediaImage.styles = `
  1921. .${ns("MediaImage")} {
  1922. display: flex;
  1923. align-items: center;
  1924. justify-content: center;
  1925. background: #000d;
  1926. }
  1927. .${ns("MediaImage")}.${ns("-zoom-pan")} {
  1928. position: fixed;
  1929. top: 0; left: 0;
  1930. width: 100%;
  1931. height: 100%;
  1932. z-index: 1000;
  1933. }
  1934. .${ns("MediaImage")} > .${ns("Spinner")} {
  1935. position: absolute;
  1936. top: 50%; left: 50%;
  1937. transform: translate(-50%, -50%);
  1938. font-size: 2em;
  1939. }
  1940. .${ns("MediaImage")} > img {
  1941. display: block;
  1942. max-width: 100%;
  1943. max-height: 100vh;
  1944. }
  1945. .${ns("MediaImage")}.${ns("-loading")} > img {
  1946. min-width: 200px;
  1947. min-height: 200px;
  1948. opacity: 0;
  1949. }
  1950. `;
  1951.  
  1952. // src/components/MediaVideo.ts
  1953. const {min: min3, max: max3, round: round4} = Math;
  1954. function MediaVideo({
  1955. url,
  1956. upscale = false,
  1957. upscaleThreshold = 0.5,
  1958. upscaleLimit = 2
  1959. }) {
  1960. const settings11 = useSettings();
  1961. const containerRef = useRef(null);
  1962. const videoRef = useRef(null);
  1963. const volumeRef = useRef(null);
  1964. const [isLoading, setIsLoading] = useState(true);
  1965. const [hasAudio, setHasAudio] = useState(false);
  1966. const [isFastForward, setIsFastForward] = useState(false);
  1967. const [error, setError] = useState(null);
  1968. const [containerWidth, containerHeight] = useElementSize(containerRef);
  1969. const [speed, setSpeed] = useState(1);
  1970. useLayoutEffect(() => {
  1971. const video = videoRef.current;
  1972. if (error || !video)
  1973. return;
  1974. let checkId = null;
  1975. const check = () => {
  1976. if (video?.videoHeight > 0) {
  1977. setHasAudio(video.audioTracks?.length > 0 || video.mozHasAudio);
  1978. setIsLoading(false);
  1979. } else {
  1980. checkId = setTimeout(check, 50);
  1981. }
  1982. };
  1983. setError(null);
  1984. setIsLoading(true);
  1985. setHasAudio(false);
  1986. setIsFastForward(false);
  1987. check();
  1988. return () => checkId != null && clearTimeout(checkId);
  1989. }, [url, error]);
  1990. useLayoutEffect(() => {
  1991. const container = containerRef.current;
  1992. const video = videoRef.current;
  1993. if (!upscale || isLoading || !video || !container || !containerWidth || !containerHeight)
  1994. return;
  1995. const naturalWidth = video.videoWidth;
  1996. const naturalHeight = video.videoHeight;
  1997. if (naturalWidth < containerWidth * upscaleThreshold && naturalHeight < containerHeight * upscaleThreshold) {
  1998. const windowAspectRatio = containerWidth / containerHeight;
  1999. const videoAspectRatio = naturalWidth / naturalHeight;
  2000. let newHeight, newWidth;
  2001. if (windowAspectRatio > videoAspectRatio) {
  2002. newHeight = min3(naturalHeight * upscaleLimit, containerHeight);
  2003. newWidth = round4(naturalWidth * (newHeight / naturalHeight));
  2004. } else {
  2005. newWidth = min3(naturalWidth * upscaleLimit, containerWidth);
  2006. newHeight = round4(naturalHeight * (newWidth / naturalWidth));
  2007. }
  2008. video.style.cssText = `width:${newWidth}px;height:${newHeight}px`;
  2009. }
  2010. return () => {
  2011. video.style.cssText = "";
  2012. };
  2013. }, [isLoading, url, upscale, upscaleThreshold, upscaleLimit, containerWidth, containerHeight]);
  2014. function initializeVolumeDragging(event) {
  2015. const volume = volumeRef.current;
  2016. if (event.button !== 0 || !volume)
  2017. return;
  2018. event.preventDefault();
  2019. event.stopPropagation();
  2020. const pointerTimelineSeek = throttle((moveEvent) => {
  2021. let {top, height} = getBoundingDocumentRect(volume);
  2022. let pos = min3(max3(1 - (moveEvent.pageY - top) / height, 0), 1);
  2023. settings11.volume = round4(pos / settings11.adjustVolumeBy) * settings11.adjustVolumeBy;
  2024. }, 100);
  2025. function unbind() {
  2026. window.removeEventListener("mousemove", pointerTimelineSeek);
  2027. window.removeEventListener("mouseup", unbind);
  2028. }
  2029. window.addEventListener("mousemove", pointerTimelineSeek);
  2030. window.addEventListener("mouseup", unbind);
  2031. pointerTimelineSeek(event);
  2032. }
  2033. function handleContainerWheel(event) {
  2034. event.preventDefault();
  2035. event.stopPropagation();
  2036. settings11.volume = min3(max3(settings11.volume + settings11.adjustVolumeBy * (event.deltaY > 0 ? -1 : 1), 0), 1);
  2037. }
  2038. const playPause = () => {
  2039. const video = videoRef.current;
  2040. if (video) {
  2041. if (video.paused || video.ended)
  2042. video.play();
  2043. else
  2044. video.pause();
  2045. }
  2046. };
  2047. const flashVolume = useMemo(() => {
  2048. let timeoutId = null;
  2049. return () => {
  2050. const volume = volumeRef.current;
  2051. if (timeoutId)
  2052. clearTimeout(timeoutId);
  2053. if (volume)
  2054. volume.style.opacity = "1";
  2055. timeoutId = setTimeout(() => {
  2056. if (volume)
  2057. volume.style.cssText = "";
  2058. }, 400);
  2059. };
  2060. }, []);
  2061. useKey(settings11.keyViewPause, playPause);
  2062. useKey(settings11.keyViewSeekBack, () => {
  2063. const video = videoRef.current;
  2064. if (video)
  2065. video.currentTime = max3(video.currentTime - settings11.seekBy, 0);
  2066. });
  2067. useKey(settings11.keyViewSeekForward, () => {
  2068. const video = videoRef.current;
  2069. if (video)
  2070. video.currentTime = min3(video.currentTime + settings11.seekBy, video.duration);
  2071. });
  2072. useKey(settings11.keyViewTinySeekBack, () => {
  2073. const video = videoRef.current;
  2074. if (video) {
  2075. video.pause();
  2076. video.currentTime = max3(video.currentTime - settings11.tinySeekBy, 0);
  2077. }
  2078. });
  2079. useKey(settings11.keyViewTinySeekForward, () => {
  2080. const video = videoRef.current;
  2081. if (video) {
  2082. video.pause();
  2083. video.currentTime = min3(video.currentTime + settings11.tinySeekBy, video.duration);
  2084. }
  2085. });
  2086. useKey(settings11.keyViewVolumeDown, () => {
  2087. settings11.volume = max3(settings11.volume - settings11.adjustVolumeBy, 0);
  2088. flashVolume();
  2089. });
  2090. useKey(settings11.keyViewVolumeUp, () => {
  2091. settings11.volume = min3(settings11.volume + settings11.adjustVolumeBy, 1);
  2092. flashVolume();
  2093. });
  2094. useKey(settings11.keyViewSpeedDown, () => setSpeed((speed2) => Math.max(settings11.adjustSpeedBy, speed2 - settings11.adjustSpeedBy)));
  2095. useKey(settings11.keyViewSpeedUp, () => setSpeed((speed2) => speed2 + settings11.adjustSpeedBy));
  2096. useKey(settings11.keyViewSpeedReset, () => setSpeed(1));
  2097. useKey(settings11.keyViewFastForward, (event) => {
  2098. if (event.repeat)
  2099. return;
  2100. if (settings11.fastForwardActivation === "hold")
  2101. setIsFastForward(true);
  2102. else
  2103. setIsFastForward((value) => !value);
  2104. });
  2105. useKeyUp(settings11.keyViewFastForward, () => {
  2106. if (settings11.fastForwardActivation === "hold")
  2107. setIsFastForward(false);
  2108. });
  2109. for (let index of [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]) {
  2110. useKey(settings11[`keyViewSeekTo${index * 10}`], () => {
  2111. const video = videoRef.current;
  2112. if (video) {
  2113. if (video.duration > 0)
  2114. video.currentTime = video.duration * (index / 10);
  2115. }
  2116. });
  2117. }
  2118. if (error)
  2119. return h(ErrorBox, {error});
  2120. let classNames = ns("MediaVideo");
  2121. if (isLoading)
  2122. classNames += ` ${ns("-loading")}`;
  2123. return h("div", {
  2124. class: ns("MediaVideo"),
  2125. ref: containerRef,
  2126. onMouseDown: ({button}) => button === 0 && playPause(),
  2127. onWheel: handleContainerWheel
  2128. }, [
  2129. isLoading && h(Spinner, null),
  2130. h("video", {
  2131. ref: videoRef,
  2132. autoplay: true,
  2133. preload: false,
  2134. controls: false,
  2135. loop: true,
  2136. volume: settings11.volume,
  2137. playbackRate: isFastForward ? settings11.fastForwardRate : speed,
  2138. onError: () => setError(new Error("Video failed to load")),
  2139. src: url
  2140. }),
  2141. h(VideoTimeline, {videoRef}),
  2142. h("div", {
  2143. class: ns("volume"),
  2144. ref: volumeRef,
  2145. onMouseDown: initializeVolumeDragging,
  2146. style: hasAudio ? "display: hidden" : ""
  2147. }, h("div", {
  2148. class: ns("bar"),
  2149. style: `height: ${Number(settings11.volume) * 100}%`
  2150. })),
  2151. speed !== 1 && h("div", {class: ns("speed")}, `${speed.toFixed(2)}x`)
  2152. ]);
  2153. }
  2154. function VideoTimeline({videoRef}) {
  2155. const settings11 = useSettings();
  2156. const [state, setState] = useState({progress: 0, elapsed: 0, remaining: 0, duration: 0});
  2157. const [bufferedRanges, setBufferedRanges] = useState([]);
  2158. const timelineRef = useRef(null);
  2159. useEffect(() => {
  2160. const video = videoRef.current;
  2161. const timeline = timelineRef.current;
  2162. if (!video || !timeline)
  2163. return;
  2164. const handleTimeupdate = () => {
  2165. setState({
  2166. progress: video.currentTime / video.duration,
  2167. elapsed: video.currentTime,
  2168. remaining: video.duration - video.currentTime,
  2169. duration: video.duration
  2170. });
  2171. };
  2172. const handleMouseDown = (event) => {
  2173. if (event.button !== 0)
  2174. return;
  2175. event.preventDefault();
  2176. event.stopPropagation();
  2177. const wasPaused = video.paused;
  2178. const pointerTimelineSeek = throttle((mouseEvent) => {
  2179. video.pause();
  2180. let {left, width} = getBoundingDocumentRect(timeline);
  2181. let pos = min3(max3((mouseEvent.pageX - left) / width, 0), 1);
  2182. video.currentTime = pos * video.duration;
  2183. }, 100);
  2184. const unbind = () => {
  2185. if (!wasPaused)
  2186. video.play();
  2187. window.removeEventListener("mousemove", pointerTimelineSeek);
  2188. window.removeEventListener("mouseup", unbind);
  2189. };
  2190. window.addEventListener("mousemove", pointerTimelineSeek);
  2191. window.addEventListener("mouseup", unbind);
  2192. pointerTimelineSeek(event);
  2193. };
  2194. const handleWheel = (event) => {
  2195. event.preventDefault();
  2196. event.stopPropagation();
  2197. video.currentTime = video.currentTime + 5 * (event.deltaY > 0 ? 1 : -1);
  2198. };
  2199. const handleProgress = () => {
  2200. const buffer = video.buffered;
  2201. const duration = video.duration;
  2202. const ranges = [];
  2203. for (let i = 0; i < buffer.length; i++) {
  2204. ranges.push({
  2205. start: buffer.start(i) / duration,
  2206. end: buffer.end(i) / duration
  2207. });
  2208. }
  2209. setBufferedRanges(ranges);
  2210. };
  2211. const progressInterval = setInterval(() => {
  2212. handleProgress();
  2213. if (video.buffered.length > 0 && video.buffered.end(video.buffered.length - 1) == video.duration) {
  2214. clearInterval(progressInterval);
  2215. }
  2216. }, 200);
  2217. video.addEventListener("timeupdate", handleTimeupdate);
  2218. timeline.addEventListener("wheel", handleWheel);
  2219. timeline.addEventListener("mousedown", handleMouseDown);
  2220. return () => {
  2221. video.removeEventListener("timeupdate", handleTimeupdate);
  2222. timeline.removeEventListener("wheel", handleWheel);
  2223. timeline.removeEventListener("mousedown", handleMouseDown);
  2224. };
  2225. }, []);
  2226. const elapsedTime = formatSeconds(state.elapsed);
  2227. const totalTime = settings11.endTimeFormat === "total" ? formatSeconds(state.duration) : `-${formatSeconds(state.remaining)}`;
  2228. return h("div", {class: ns("timeline"), ref: timelineRef}, [
  2229. ...bufferedRanges.map(({start, end}) => h("div", {
  2230. class: ns("buffered-range"),
  2231. style: {
  2232. left: `${start * 100}%`,
  2233. right: `${100 - end * 100}%`
  2234. }
  2235. })),
  2236. h("div", {class: ns("elapsed")}, elapsedTime),
  2237. h("div", {class: ns("total")}, totalTime),
  2238. h("div", {class: ns("progress"), style: `width: ${state.progress * 100}%`}, [
  2239. h("div", {class: ns("elapsed")}, elapsedTime),
  2240. h("div", {class: ns("total")}, totalTime)
  2241. ])
  2242. ]);
  2243. }
  2244. MediaVideo.styles = `
  2245. .${ns("MediaVideo")} {
  2246. --timeline-max-size: 40px;
  2247. --timeline-min-size: 20px;
  2248. position: relative;
  2249. display: flex;
  2250. max-width: 100%;
  2251. max-height: 100vh;
  2252. align-items: center;
  2253. justify-content: center;
  2254. background: #000d;
  2255. }
  2256. .${ns("MediaVideo")} > .${ns("Spinner")} {
  2257. position: absolute;
  2258. top: 50%; left: 50%;
  2259. transform: translate(-50%, -50%);
  2260. font-size: 2em;
  2261. }
  2262. .${ns("MediaVideo")} > video {
  2263. display: block;
  2264. max-width: 100%;
  2265. max-height: calc(100vh - var(--timeline-min-size));
  2266. margin: 0 auto var(--timeline-min-size);
  2267. outline: none;
  2268. background: #000d;
  2269. }
  2270. .${ns("MediaVideo")}.${ns("-loading")} > video {
  2271. min-width: 200px;
  2272. min-height: 200px;
  2273. opacity: 0;
  2274. }
  2275. .${ns("MediaVideo")} > .${ns("timeline")} {
  2276. position: absolute;
  2277. left: 0; bottom: 0;
  2278. width: 100%;
  2279. height: var(--timeline-max-size);
  2280. font-size: 14px !important;
  2281. line-height: 1;
  2282. color: #eee;
  2283. background: #111c;
  2284. border: 1px solid #111c;
  2285. transition: height 100ms ease-out;
  2286. user-select: none;
  2287. }
  2288. .${ns("MediaVideo")}:not(:hover) > .${ns("timeline")},
  2289. .${ns("MediaVideo")}.${ns("zoomed")} > .${ns("timeline")} {
  2290. height: var(--timeline-min-size);
  2291. }
  2292. .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("buffered-range")} {
  2293. position: absolute;
  2294. bottom: 0;
  2295. height: 100%;
  2296. background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAFUlEQVQImWNgQAL/////TyqHgYEBAB5CD/FVFp/QAAAAAElFTkSuQmCC') left bottom repeat;
  2297. opacity: .17;
  2298. transition: right 200ms ease-out;
  2299. }
  2300. .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("progress")} {
  2301. height: 100%;
  2302. background: #eee;
  2303. clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
  2304. }
  2305. .${ns("MediaVideo")} > .${ns("timeline")} .${ns("elapsed")},
  2306. .${ns("MediaVideo")} > .${ns("timeline")} .${ns("total")} {
  2307. position: absolute;
  2308. top: 0;
  2309. height: 100%;
  2310. display: flex;
  2311. justify-content: center;
  2312. align-items: center;
  2313. padding: 0 .2em;
  2314. 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;
  2315. pointer-events: none;
  2316. }
  2317. .${ns("MediaVideo")} > .${ns("timeline")} .${ns("elapsed")} {left: 0;}
  2318. .${ns("MediaVideo")} > .${ns("timeline")} .${ns("total")} {right: 0;}
  2319. .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("progress")} .${ns("elapsed")},
  2320. .${ns("MediaVideo")} > .${ns("timeline")} > .${ns("progress")} .${ns("total")} {
  2321. color: #111;
  2322. text-shadow: none;
  2323. }
  2324.  
  2325. .${ns("MediaVideo")} > .${ns("volume")} {
  2326. position: absolute;
  2327. right: 10px;
  2328. top: calc(25% - var(--timeline-min-size));
  2329. width: 30px;
  2330. height: 50%;
  2331. background: #111c;
  2332. border: 1px solid #111c;
  2333. transition: opacity 100ms linear;
  2334. }
  2335. .${ns("MediaVideo")}:not(:hover) > .${ns("volume")} {opacity: 0;}
  2336. .${ns("MediaVideo")} > .${ns("volume")} > .${ns("bar")} {
  2337. position: absolute;
  2338. left: 0;
  2339. bottom: 0;
  2340. width: 100%;
  2341. background: #eee;
  2342. }
  2343. .${ns("MediaVideo")} > .${ns("speed")} {
  2344. position: absolute;
  2345. left: 10px;
  2346. top: 10px;
  2347. padding: .5em .7em;
  2348. font-size: 0.9em;
  2349. font-family: "Lucida Console", Monaco, monospace;
  2350. color: #fff;
  2351. text-shadow: 1px 1px 0 #000a, -1px -1px 0 #000a, -1px 1px 0 #000a, 1px -1px 0 #000a, 0 1px 0 #000a, 1px 0 0 #000a;
  2352. }
  2353. `;
  2354.  
  2355. // src/components/MediaView.ts
  2356. function MediaView({media: {url, isVideo}}) {
  2357. const settings11 = useSettings();
  2358. const containerRef = useRef(null);
  2359. const [isExpanded, setIsExpanded] = useState(false);
  2360. const [isFullScreen, setIsFullScreen] = useState(false);
  2361. const toggleFullscreen = () => {
  2362. if (containerRef.current) {
  2363. if (!document.fullscreenElement) {
  2364. setIsFullScreen(true);
  2365. containerRef.current.requestFullscreen().catch((error) => {
  2366. setIsFullScreen(false);
  2367. });
  2368. } else {
  2369. setIsFullScreen(false);
  2370. document.exitFullscreen();
  2371. }
  2372. }
  2373. };
  2374. useKey(settings11.keyViewFullScreen, toggleFullscreen);
  2375. useKey(settings11.keyViewFullPage, (event) => {
  2376. event.preventDefault();
  2377. if (event.repeat)
  2378. return;
  2379. if (settings11.fpmActivation === "hold")
  2380. setIsExpanded(true);
  2381. else
  2382. setIsExpanded((value) => !value);
  2383. });
  2384. useKeyUp(settings11.keyViewFullPage, () => {
  2385. if (settings11.fpmActivation === "hold")
  2386. setIsExpanded(false);
  2387. });
  2388. let classNames = ns("MediaView");
  2389. if (isExpanded || isFullScreen)
  2390. classNames += ` ${ns("-expanded")}`;
  2391. return h("div", {class: classNames, ref: containerRef, onDblClick: toggleFullscreen}, isVideo ? h(MediaVideo, {
  2392. key: url,
  2393. url,
  2394. upscale: isExpanded || isFullScreen,
  2395. upscaleThreshold: settings11.fpmVideoUpscaleThreshold,
  2396. upscaleLimit: settings11.fpmVideoUpscaleLimit
  2397. }) : h(MediaImage, {
  2398. key: url,
  2399. url,
  2400. upscale: isExpanded || isFullScreen,
  2401. upscaleThreshold: settings11.fpmImageUpscaleThreshold,
  2402. upscaleLimit: settings11.fpmImageUpscaleLimit
  2403. }));
  2404. }
  2405. MediaView.styles = `
  2406. .${ns("MediaView")} {
  2407. position: absolute;
  2408. top: 0; right: 0;
  2409. max-width: calc(100% - var(--media-list-width));
  2410. max-height: 100vh;
  2411. display: flex;
  2412. flex-direction: column;
  2413. align-items: center;
  2414. align-content: center;
  2415. justify-content: center;
  2416. }
  2417. .${ns("MediaView")} > * {
  2418. width: 100%;
  2419. height: 100%;
  2420. max-width: 100%;
  2421. max-height: 100vh;
  2422. }
  2423. .${ns("MediaView")}.${ns("-expanded")} {
  2424. max-width: 100%;
  2425. width: 100vw;
  2426. height: 100vh;
  2427. z-index: 1000;
  2428. }
  2429. .${ns("MediaView")} > .${ns("ErrorBox")} { min-height: 200px; }
  2430. `;
  2431.  
  2432. // src/components/ThreadMediaViewer.ts
  2433. const {round: round5} = Math;
  2434. function ThreadMediaViewer({settings: settings11, watcher}) {
  2435. const containerRef = useRef(null);
  2436. const [isOpen, setIsOpen] = useState(false);
  2437. const [sideView, setSideView] = useState(null);
  2438. const [activeIndex, setActiveIndex] = useState(null);
  2439. const [windowWidth] = useWindowDimensions();
  2440. const forceUpdate = useForceUpdate();
  2441. useEffect(() => {
  2442. return watcher.subscribe(forceUpdate);
  2443. }, [watcher]);
  2444. useEffect(() => {
  2445. return settings11._subscribe(forceUpdate);
  2446. }, [settings11]);
  2447. useEffect(() => {
  2448. const container = containerRef.current;
  2449. if (container) {
  2450. const cappedListWidth = clamp(300, settings11.mediaListWidth, window.innerWidth - 300);
  2451. container.style.setProperty("--media-list-width", `${cappedListWidth}px`);
  2452. const itemHeight = round5((cappedListWidth - 10) / settings11.mediaListItemsPerRow);
  2453. container.style.setProperty("--media-list-item-height", `${itemHeight}px`);
  2454. const cappedListHeight = clamp(200 / window.innerHeight, settings11.mediaListHeight, 1 - 200 / window.innerHeight);
  2455. container.style.setProperty("--media-list-height", `${cappedListHeight * 100}vh`);
  2456. container.style.setProperty("--media-list-items-per-row", `${settings11.mediaListItemsPerRow}`);
  2457. }
  2458. }, [windowWidth, settings11.mediaListWidth, settings11.mediaListHeight, settings11.mediaListItemsPerRow]);
  2459. useEffect(() => {
  2460. function handleClick(event) {
  2461. const target = event.target;
  2462. if (!isOfType(target, !!target && "closest" in target))
  2463. return;
  2464. const url = target?.closest("a")?.href;
  2465. if (url && watcher.mediaByURL.has(url)) {
  2466. const mediaIndex = watcher.media.findIndex((media) => media.url === url);
  2467. if (mediaIndex != null) {
  2468. event.stopPropagation();
  2469. event.preventDefault();
  2470. setActiveIndex(mediaIndex);
  2471. if (event.shiftKey)
  2472. setIsOpen(true);
  2473. }
  2474. }
  2475. }
  2476. watcher.container.addEventListener("click", handleClick);
  2477. return () => {
  2478. watcher.container.removeEventListener("click", handleClick);
  2479. };
  2480. }, []);
  2481. const closeSideView = () => setSideView(null);
  2482. function toggleList() {
  2483. setIsOpen((isOpen2) => {
  2484. setSideView(null);
  2485. return !isOpen2;
  2486. });
  2487. }
  2488. function onOpenSideView(newView) {
  2489. setSideView((view) => view === newView ? null : newView);
  2490. }
  2491. useKey(settings11.keyToggleUI, toggleList);
  2492. useKey(settings11.keyViewClose, () => setActiveIndex(null));
  2493. useGesture("up", toggleList);
  2494. useGesture("down", () => setActiveIndex(null));
  2495. let SideViewContent;
  2496. if (sideView === "help")
  2497. SideViewContent = Help;
  2498. if (sideView === "settings")
  2499. SideViewContent = Settings;
  2500. if (sideView === "changelog")
  2501. SideViewContent = Changelog;
  2502. return h(SettingsProvider, {value: settings11}, h("div", {class: `${ns("ThreadMediaViewer")} ${isOpen ? ns("-is-open") : ""}`, ref: containerRef}, [
  2503. isOpen && h(MediaList, {
  2504. media: watcher.media,
  2505. activeIndex,
  2506. sideView,
  2507. onActivation: setActiveIndex,
  2508. onOpenSideView
  2509. }),
  2510. SideViewContent != null && h(SideView, {key: sideView, onClose: closeSideView}, h(SideViewContent, null)),
  2511. activeIndex != null && watcher.media[activeIndex] && h(MediaView, {media: watcher.media[activeIndex]})
  2512. ]));
  2513. }
  2514. ThreadMediaViewer.styles = `
  2515. .${ns("ThreadMediaViewer")} {
  2516. --media-list-width: 640px;
  2517. --media-list-height: 50vh;
  2518. --media-list-items-per-row: 3;
  2519. --media-list-item-height: 160px;
  2520. position: fixed;
  2521. top: 0;
  2522. left: 0;
  2523. width: 100%;
  2524. height: 0;
  2525. }
  2526. `;
  2527.  
  2528. // src/components/CatalogNavigator.ts
  2529. const {min: min4, max: max4, sqrt, pow} = Math;
  2530. const get2DDistance = (ax, ay, bx, by) => sqrt(pow(ax - bx, 2) + pow(ay - by, 2));
  2531. function CatalogNavigator({settings: settings11, watcher}) {
  2532. const catalogContainerRef = useRef(watcher.container);
  2533. const itemsPerRow = useItemsPerRow(catalogContainerRef);
  2534. const cursorRef = useRef(null);
  2535. const [sideView, setSideView] = useState(null);
  2536. const [selectedIndex, setSelectedIndex] = useState(null);
  2537. const forceUpdate = useForceUpdate();
  2538. const [windowWidth, windowHeight] = useWindowDimensions();
  2539. const selectedThread = selectedIndex != null ? watcher.threads[selectedIndex] : void 0;
  2540. const enabled = settings11.catalogNavigator;
  2541. useEffect(() => watcher.subscribe(forceUpdate), [watcher]);
  2542. useEffect(() => settings11._subscribe(forceUpdate), [settings11]);
  2543. useEffect(() => {
  2544. if (selectedThread && !enabled) {
  2545. setSelectedIndex(null);
  2546. return;
  2547. }
  2548. if (enabled && !selectedThread && watcher.threads.length > 0) {
  2549. const centerX = window.innerWidth / 2;
  2550. const centerY = window.innerHeight / 2;
  2551. let closest = {distance: Infinity, index: null};
  2552. for (let i = 0; i < watcher.threads.length; i++) {
  2553. const rect = watcher.threads[i].container.getBoundingClientRect();
  2554. const distance = get2DDistance(rect.left + rect.width / 2, rect.top + rect.height / 2, centerX, centerY);
  2555. if (distance < closest.distance) {
  2556. closest.distance = distance;
  2557. closest.index = i;
  2558. }
  2559. }
  2560. if (closest.index != null)
  2561. setSelectedIndex(closest.index);
  2562. }
  2563. }, [selectedThread, watcher.threads, enabled]);
  2564. useEffect(() => {
  2565. const cursor = cursorRef.current;
  2566. if (!cursor || !selectedThread || !enabled)
  2567. return;
  2568. const rect = getBoundingDocumentRect(selectedThread.container);
  2569. Object.assign(cursor.style, {
  2570. left: `${rect.left - 4}px`,
  2571. top: `${rect.top - 4}px`,
  2572. width: `${rect.width + 8}px`,
  2573. height: `${rect.height + 8}px`
  2574. });
  2575. }, [selectedThread, watcher.threads, windowWidth, itemsPerRow, enabled]);
  2576. useEffect(() => {
  2577. function handleCLick(event) {
  2578. const target = event.target;
  2579. if (!isOfType(target, !!target && "closest" in target))
  2580. return;
  2581. const threadContainer = target.closest(`${watcher.serializer.selector} > *`);
  2582. if (threadContainer) {
  2583. const index = watcher.threads.findIndex((thread) => thread.container === threadContainer);
  2584. if (index != null)
  2585. setSelectedIndex(index);
  2586. }
  2587. }
  2588. watcher.container.addEventListener("click", handleCLick);
  2589. return () => watcher.container.removeEventListener("click", handleCLick);
  2590. }, [watcher.container]);
  2591. const navToIndex = (index) => {
  2592. const clampedIndex = max4(0, min4(watcher.threads.length - 1, index));
  2593. const selectedThreadContainer = watcher.threads[clampedIndex].container;
  2594. if (selectedThreadContainer) {
  2595. setSelectedIndex(clampedIndex);
  2596. scrollToView(selectedThreadContainer, {block: window.innerHeight / 2 - 200, behavior: "smooth"});
  2597. }
  2598. };
  2599. const navBy = (amount) => selectedIndex != null && navToIndex(selectedIndex + amount);
  2600. const toggleSettings = () => setSideView(sideView ? null : "settings");
  2601. useKey(settings11.keyToggleUI, toggleSettings);
  2602. useKey(enabled && settings11.keyNavLeft, () => navBy(-1));
  2603. useKey(enabled && settings11.keyNavRight, () => navBy(1));
  2604. useKey(enabled && settings11.keyNavUp, () => navBy(-itemsPerRow));
  2605. useKey(enabled && settings11.keyNavDown, () => navBy(+itemsPerRow));
  2606. useKey(enabled && settings11.keyNavPageBack, () => navBy(-itemsPerRow * 3));
  2607. useKey(enabled && settings11.keyNavPageForward, () => navBy(+itemsPerRow * 3));
  2608. useKey(enabled && settings11.keyNavStart, () => navToIndex(0));
  2609. useKey(enabled && settings11.keyNavEnd, () => navToIndex(Infinity));
  2610. useKey(enabled && settings11.keyCatalogOpenThread, () => selectedThread && (location.href = selectedThread.url));
  2611. useKey(enabled && settings11.keyCatalogOpenThreadInNewTab, () => {
  2612. if (selectedThread)
  2613. GM_openInTab(selectedThread.url, {active: true});
  2614. });
  2615. useKey(settings11.keyCatalogOpenThreadInBackgroundTab, () => selectedThread && GM_openInTab(selectedThread.url));
  2616. useGesture("up", toggleSettings);
  2617. let SideViewContent;
  2618. if (sideView === "help")
  2619. SideViewContent = Help;
  2620. if (sideView === "settings")
  2621. SideViewContent = Settings;
  2622. if (sideView === "changelog")
  2623. SideViewContent = Changelog;
  2624. let classNames = ns("CatalogNavigator");
  2625. if (sideView)
  2626. classNames += ` ${ns("-is-open")}`;
  2627. return h(SettingsProvider, {value: settings11}, [
  2628. enabled && selectedThread && h("div", {class: ns("CatalogCursor"), ref: cursorRef}),
  2629. SideViewContent && h("div", {class: classNames}, [
  2630. h(SideView, {key: sideView, onClose: () => setSideView(null)}, h(SideViewContent, null)),
  2631. h(SideNav, {active: sideView, onActive: setSideView})
  2632. ])
  2633. ]);
  2634. }
  2635. CatalogNavigator.styles = `
  2636. .${ns("CatalogCursor")} {
  2637. position: absolute;
  2638. border: 2px dashed #fff8;
  2639. border-radius: 2px;
  2640. transition: all 66ms cubic-bezier(0.25, 1, 0.5, 1);
  2641. pointer-events: none;
  2642. }
  2643. .${ns("CatalogCursor")}:before {
  2644. content: '';
  2645. display: block;
  2646. width: 100%;
  2647. height: 100%;
  2648. border: 2px dashed #0006;
  2649. border-radius: 2;
  2650. }
  2651. .${ns("CatalogNavigator")} {
  2652. --media-list-width: 640px;
  2653. --media-list-height: 50vh;
  2654. position: fixed;
  2655. top: 0;
  2656. left: 0;
  2657. width: 100%;
  2658. height: 0;
  2659. }
  2660. .${ns("CatalogNavigator")} > .${ns("SideNav")} {
  2661. position: fixed;
  2662. left: 2px;
  2663. bottom: calc(var(--media-list-height) - 0.2em);
  2664. padding: 2px;
  2665. border-radius: 3px;
  2666. background: #161616;
  2667. }
  2668.  
  2669. `;
  2670.  
  2671. // src/styles.ts
  2672. const componentStyles = [
  2673. ThreadMediaViewer,
  2674. CatalogNavigator,
  2675. ErrorBox,
  2676. MediaImage,
  2677. MediaList,
  2678. MediaVideo,
  2679. MediaView,
  2680. Settings,
  2681. SideNav,
  2682. SideView,
  2683. Spinner
  2684. ].map(({styles: styles2}) => styles2).join("\n");
  2685. const baseStyles = `
  2686. .${ns("CONTAINER")},
  2687. .${ns("CONTAINER")} *,
  2688. .${ns("CONTAINER")} *:before,
  2689. .${ns("CONTAINER")} *:after {
  2690. box-sizing: border-box;
  2691. font-family: inherit;
  2692. line-height: 1.4;
  2693. }
  2694. .${ns("CONTAINER")} {
  2695. font-family: arial, helvetica, sans-serif;
  2696. font-size: 16px;
  2697. color: #aaa;
  2698. }
  2699.  
  2700. .${ns("CONTAINER")} a { color: #c4b256 !important; }
  2701. .${ns("CONTAINER")} a:hover { color: #fde981 !important; }
  2702. .${ns("CONTAINER")} a:active { color: #000 !important; }
  2703.  
  2704. .${ns("CONTAINER")} input,
  2705. .${ns("CONTAINER")} button {
  2706. box-sizing: border-box;
  2707. display: inline-block;
  2708. vertical-align: middle;
  2709. margin: 0;
  2710. padding: 0 0.3em;
  2711. height: 1.6em;
  2712. font-size: inherit;
  2713. border-radius: 2px;
  2714. }
  2715. .${ns("CONTAINER")} input:focus { box-shadow: 0 0 0 3px #fff2; }
  2716. .${ns("CONTAINER")} input[type=text] {
  2717. border: 0 !important;
  2718. width: 8em;
  2719. font-family: "Lucida Console", Monaco, monospace;
  2720. color: #222;
  2721. }
  2722. .${ns("CONTAINER")} input[type=text].small { width: 4em; }
  2723. .${ns("CONTAINER")} input[type=text].large { width: 12em; }
  2724. .${ns("CONTAINER")} input[type=range] { width: 10em; }
  2725. .${ns("CONTAINER")} input[type=radio],
  2726. .${ns("CONTAINER")} input[type=range],
  2727.  
  2728. .${ns("CONTAINER")} input[type=checkbox] { padding: 0; }
  2729. .${ns("CONTAINER")} button {
  2730. color: #fff;
  2731. background: transparent;
  2732. border: 1px solid #333;
  2733. }
  2734. .${ns("CONTAINER")} button:not(:disabled):hover {
  2735. color: #222;
  2736. background: #fff;
  2737. border-color: #fff;
  2738. }
  2739. .${ns("CONTAINER")} button:disabled { opacity: .5; border-color: transparent; }
  2740.  
  2741. .${ns("CONTAINER")} h1,
  2742. .${ns("CONTAINER")} h2,
  2743. .${ns("CONTAINER")} h3 { margin: 0; font-weight: normal; color: #fff; }
  2744. .${ns("CONTAINER")} * + h1,
  2745. .${ns("CONTAINER")} * + h2,
  2746. .${ns("CONTAINER")} * + h3 { margin-top: 1em; }
  2747. .${ns("CONTAINER")} h1 { font-size: 1.5em !important; }
  2748. .${ns("CONTAINER")} h2 { font-size: 1.2em !important; }
  2749. .${ns("CONTAINER")} h3 { font-size: 1em !important; font-weight: bold; }
  2750.  
  2751. .${ns("CONTAINER")} ul { list-style: square; padding-left: 1em; margin: 1em 0; }
  2752. .${ns("CONTAINER")} ul.${ns("-clean")} { list-style: none; }
  2753. .${ns("CONTAINER")} li { padding: 0.3em 0; list-style: inherit; }
  2754. .${ns("CONTAINER")} code {
  2755. font-family: "Lucida Console", Monaco, monospace;
  2756. padding: 0;
  2757. background-color: transparent;
  2758. color: inherit;
  2759. }
  2760.  
  2761. .${ns("CONTAINER")} pre { white-space: pre-wrap; }
  2762. .${ns("CONTAINER")} kbd {
  2763. padding: .17em .2em;
  2764. font-family: "Lucida Console", Monaco, monospace;
  2765. color: #fff;
  2766. font-size: .95em;
  2767. border-radius: 2px;
  2768. background: #363f44;
  2769. text-shadow: -1px -1px #0006;
  2770. border: 0;
  2771. box-shadow: none;
  2772. line-height: inherit;
  2773. }
  2774.  
  2775. .${ns("CONTAINER")} dl { margin: 1em 0; }
  2776. .${ns("CONTAINER")} dt { font-weight: bold; }
  2777. .${ns("CONTAINER")} dd { margin: .1em 0 .8em; color: #888; }
  2778. .${ns("CONTAINER")} [title] { cursor: help; }
  2779. .${ns("CONTAINER")} .${ns("help-indicator")} {
  2780. display: inline-block;
  2781. vertical-align: middle;
  2782. background: #333;
  2783. color: #aaa;
  2784. border-radius: 50%;
  2785. width: 1.3em;
  2786. height: 1.3em;
  2787. text-align: center;
  2788. font-size: .8em;
  2789. line-height: 1.3;
  2790. }
  2791.  
  2792. .${ns("CONTAINER")} .${ns("help-indicator")}::after { content: '?'; }
  2793. .${ns("CONTAINER")} .${ns("-muted")} { opacity: .5; }
  2794. `;
  2795. GM_addStyle(baseStyles + componentStyles);
  2796.  
  2797. // src/index.ts
  2798. const serializer = SERIALIZERS.find((serializer2) => serializer2.urlMatches.exec(location.host + location.pathname));
  2799. if (serializer) {
  2800. const {threadSerializer, catalogSerializer} = serializer;
  2801. const settings11 = syncedSettings(ns("settings"), defaultSettings);
  2802. let mediaWatcher2 = null;
  2803. let catalogWatcher2 = null;
  2804. const container = Object.assign(document.createElement("div"), {className: ns("CONTAINER")});
  2805. document.body.appendChild(container);
  2806. const refreshMounts = throttle(() => {
  2807. if (mediaWatcher2 && !document.body.contains(mediaWatcher2.container)) {
  2808. render(null, container);
  2809. mediaWatcher2.destroy();
  2810. mediaWatcher2 = null;
  2811. }
  2812. if (catalogWatcher2 && !document.body.contains(catalogWatcher2.container)) {
  2813. render(null, container);
  2814. catalogWatcher2.destroy();
  2815. catalogWatcher2 = null;
  2816. }
  2817. if (!mediaWatcher2 && !catalogWatcher2) {
  2818. if (threadSerializer) {
  2819. try {
  2820. mediaWatcher2 = new MediaWatcher(threadSerializer);
  2821. render(h(ThreadMediaViewer, {settings: settings11, watcher: mediaWatcher2}), container);
  2822. } catch (error) {
  2823. }
  2824. }
  2825. if (catalogSerializer) {
  2826. try {
  2827. catalogWatcher2 = new CatalogWatcher(catalogSerializer);
  2828. render(h(CatalogNavigator, {settings: settings11, watcher: catalogWatcher2}), container);
  2829. } catch (error) {
  2830. }
  2831. }
  2832. }
  2833. }, 100);
  2834. new MutationObserver(refreshMounts).observe(document.body, {childList: true, subtree: true});
  2835. refreshMounts();
  2836. }
  2837. })();