Thread Media Viewer

Comfy and efficient way how to navigate media files in a thread. Currently set up for 4chan and thebarchive.

当前为 2020-08-18 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Thread Media Viewer
  3. // @description Comfy and efficient way how to navigate media files in a thread. Currently set up for 4chan and thebarchive.
  4. // @version 1.1.1
  5. // @namespace qimasho
  6. // @match https://boards.4chan.org/*
  7. // @match https://boards.4channel.org/*
  8. // @match https://thebarchive.com/*
  9. // @require https://cdn.jsdelivr.net/npm/preact@10.4.6/dist/preact.min.js
  10. // @require https://cdn.jsdelivr.net/npm/preact@10.4.6/hooks/dist/hooks.umd.js
  11. // @grant GM_addStyle
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. const WEBSITES = [
  16. {
  17. urlRegexp: /boards\.4chan(nel)?.org\/\w+\/thread\/\S+/i,
  18. threadSelector: '.board .thread',
  19. postSelector: '.post',
  20. serialize: (post) => {
  21. const titleAnchor = post.querySelector('.fileText a');
  22. const url = post.querySelector('a.fileThumb')?.href;
  23. return {
  24. meta: post.querySelector('.fileText')?.textContent.match(/\(([^\(\)]+ *, *\d+x\d+)\)/)?.[1],
  25. url,
  26. thumbnailUrl: post.querySelector('a.fileThumb img')?.src,
  27. title: titleAnchor?.title || titleAnchor?.textContent || url?.match(/\/([^\/]+)$/)?.[1],
  28. replies: post.querySelectorAll('.postInfo .backlink a.quotelink')?.length ?? 0,
  29. };
  30. },
  31. },
  32. {
  33. urlRegexp: /thebarchive\.com\/b\/thread\/\S+/i,
  34. threadSelector: '.thread .posts',
  35. postSelector: '.post',
  36. serialize: (post) => {
  37. const titleElement = post.querySelector('.post_file_filename');
  38. const url = post.querySelector('a.thread_image_link')?.href;
  39. return {
  40. meta: post.querySelector('.post_file_metadata')?.textContent,
  41. url,
  42. thumbnailUrl: post.querySelector('img.post_image')?.src,
  43. title: titleElement?.title || titleElement?.textContent || url?.match(/\/([^\/]+)$/)?.[1],
  44. replies: post.querySelectorAll('.backlink_list a.backlink')?.length ?? 0,
  45. };
  46. },
  47. },
  48. ];
  49. // @ts-ignore
  50. const {h, render} = preact;
  51. // @ts-ignore
  52. const {useState, useEffect, useRef, useMemo, useCallback} = preactHooks;
  53. const {round, min, max, hypot, abs, floor} = Math;
  54. const INTERACTIVE = {INPUT: true, TEXTAREA: true, SELECT: true};
  55. const cn = (name) => `_tm_media_browser_` + name;
  56. const log = (...args) => console.log('TMV:', ...args);
  57. const storage = syncedStorage(cn('storage'), {volume: 0.5});
  58. const CONFIG = {
  59. adjustVolumeBy: 0.125,
  60. seekBy: 5,
  61. gestureDistance: 30,
  62. totalTime: true,
  63. };
  64.  
  65. const website = WEBSITES.find((config) => config.urlRegexp.exec(location.href));
  66.  
  67. if (website) {
  68. log('url matched', website.urlRegexp);
  69. const threadElement = document.querySelector(website.threadSelector);
  70. const watcher = mediaWatcher(website);
  71. const container = Object.assign(document.createElement('div'), {className: cn('container')});
  72.  
  73. document.body.appendChild(container);
  74. render(h(App, {watcher, threadElement}), container);
  75. }
  76.  
  77. function App({watcher, threadElement}) {
  78. const [isOpen, setIsOpen] = useState(false);
  79. const [showHelp, setShowHelp] = useState(false);
  80. const media = useThreadMedia(watcher);
  81. const [activeIndex, setActiveIndex] = useState(null);
  82.  
  83. const toggleHelp = useCallback(() => setShowHelp((value) => !value), []);
  84. const closeHelp = useCallback(() => setShowHelp(false), []);
  85.  
  86. // Shortcuts
  87. useKey('`', () => {
  88. setIsOpen((isOpen) => {
  89. setShowHelp((showHelp) => !isOpen && showHelp);
  90. return !isOpen;
  91. });
  92. });
  93. useKey('~', () => setShowHelp((value) => !value));
  94. useKey('F', () => setActiveIndex(null));
  95.  
  96. // Intercept clicks to media files and open them in MediaBrowser
  97. useEffect(() => {
  98. function handleClick(event) {
  99. const url = event.target?.closest('a')?.href;
  100. if (url && watcher.mediaByURL.has(url)) {
  101. const mediaIndex = watcher.media.findIndex((media) => media.url === url);
  102. if (mediaIndex != null) {
  103. event.stopPropagation();
  104. event.preventDefault();
  105. setActiveIndex(mediaIndex);
  106. if (event.shiftKey) setIsOpen(true);
  107. }
  108. }
  109. }
  110. threadElement.addEventListener('click', handleClick);
  111. return () => {
  112. threadElement.removeEventListener('click', handleClick);
  113. };
  114. }, []);
  115.  
  116. // Mouse gestures
  117. useEffect(() => {
  118. let gestureStart = null;
  119.  
  120. function handleMouseDown({which, x, y}) {
  121. if (which === 3) gestureStart = {x, y};
  122. }
  123.  
  124. function handleMouseUp({which, x, y}) {
  125. if (which !== 3 || !gestureStart) return;
  126.  
  127. const dragDistance = hypot(x - gestureStart.x, y - gestureStart.y);
  128.  
  129. if (dragDistance < CONFIG.gestureDistance) return;
  130.  
  131. let gesture;
  132. if (abs(gestureStart.x - x) < dragDistance / 2) {
  133. gesture = gestureStart.y < y ? 'down' : 'up';
  134. }
  135.  
  136. switch (gesture) {
  137. case 'down':
  138. setActiveIndex(null);
  139. break;
  140. case 'up':
  141. setIsOpen((isOpen) => !isOpen);
  142. break;
  143. }
  144.  
  145. // Clear and prevent context menu
  146. gestureStart = null;
  147. if (gesture) {
  148. const preventContext = (event) => event.preventDefault();
  149. window.addEventListener('contextmenu', preventContext, {once: true});
  150. // Unbind after a couple milliseconds to not clash with other
  151. // tools that prevent context, such as gesture extensions.
  152. setTimeout(() => window.removeEventListener('contextmenu', preventContext), 10);
  153. }
  154. }
  155.  
  156. window.addEventListener('mousedown', handleMouseDown);
  157. window.addEventListener('mouseup', handleMouseUp);
  158.  
  159. return () => {
  160. window.removeEventListener('mousedown', handleMouseDown);
  161. window.removeEventListener('mouseup', handleMouseUp);
  162. };
  163. }, []);
  164.  
  165. return h('div', {class: `${cn('MediaBrowser')} ${isOpen ? cn('-is-open') : ''}`}, [
  166. isOpen &&
  167. h(Gallery, {
  168. key: 'list',
  169. media,
  170. activeIndex,
  171. onActivation: setActiveIndex,
  172. onToggleHelp: toggleHelp,
  173. }),
  174. showHelp && h(Help, {onClose: closeHelp}),
  175. activeIndex != null && media[activeIndex] && h(MediaView, {item: media[activeIndex]}),
  176. ]);
  177. }
  178.  
  179. function Help({onClose}) {
  180. return h('div', {class: cn('Help')}, [
  181. h('button', {class: cn('close'), onClick: onClose}, '×'),
  182. h('h2', {}, 'Mouse controls'),
  183. h('ul', {}, [
  184. h('li', {}, ['Right button gesture ', h('code', {}, '↑'), ' to toggle media list.']),
  185. h('li', {}, ['Right button gesture ', h('code', {}, '↓'), ' to close media view.']),
  186. h('li', {}, [h('code', {}, 'click'), ' on thumbnail (thread or list) to open media viewer.']),
  187. h('li', {}, [h('code', {}, 'shift+click'), ' on thumbnail (thread) to open both media view and list.']),
  188. h('li', {}, [h('code', {}, 'mouse wheel'), ' on video to change audio volume.']),
  189. h('li', {}, [h('code', {}, 'mouse wheel'), ' on timeline to seek video.']),
  190. h('li', {}, [h('code', {}, 'mouse down'), ' on image for 1:1 zoom, move to pan.']),
  191. ]),
  192. h('h2', {}, 'Shortcuts'),
  193. h('ul', {}, [
  194. h('li', {}, [h('code', {}, '`'), ' Toggle media list.']),
  195. h('li', {}, [h('code', {}, '~'), ' Toggle help.']),
  196. h('li', {}, [h('code', {}, 'w/a/s/d'), ' Navigate without activating.']),
  197. h('li', {}, [h('code', {}, 'W/A/S/D'), ' Navigate with activating.']),
  198. h('li', {}, [h('code', {}, 'f'), ' View selected item (toggle).']),
  199. h('li', {}, [h('code', {}, 'F'), ' Close current media view.']),
  200. h('li', {}, [h('code', {}, 'home/end'), ' Navigate to top/bottom.']),
  201. h('li', {}, [h('code', {}, 'pageUp/pageDown'), ' Navigate one screen up/down.']),
  202. h('li', {}, [
  203. h('code', {}, 'tab (hold)'),
  204. ' Full page media view (also, videos that cover less than half of available space receive 2x zoom).',
  205. ]),
  206. h('li', {}, [h('code', {}, 'q/e'), ' Seek video backward/forward.']),
  207. h('li', {}, [h('code', {}, '0-9'), ' Seek video to a tenth percentile (1=10%).']),
  208. h('li', {}, [h('code', {}, 'space'), ' Pause/Play.']),
  209. h('li', {}, [h('code', {}, 'shift+space (hold)'), ' Fast forward (x5).']),
  210. h('li', {}, [h('code', {}, 'Q/E'), ' Decrease/increase audio volume.']),
  211. ]),
  212. h('h2', {}, 'FAQ'),
  213. h('dl', {}, [
  214. h('dt', {}, "Why does the page scroll when I'm navigating items?"),
  215. h('dd', {}, 'It scrolls to place the associated post right below the media list box.'),
  216. h('dt', {}, 'What are the small squares at the bottom of thumbnails?'),
  217. h('dd', {}, 'Visualization of the number of replies the post has.'),
  218. ]),
  219. ]);
  220. }
  221.  
  222. function Gallery({media, activeIndex, onActivation, onToggleHelp}) {
  223. const mainContainer = useRef(null);
  224. const listContainer = useRef(null);
  225. let [selectedIndex, setSelectedIndex] = useState(activeIndex);
  226. let [windowWidth] = useWindowDimensions();
  227. let itemsPerRow = useItemsPerRow(listContainer, [windowWidth, media.length]);
  228.  
  229. // If there is no selected item, select the item closest to the center of the screen
  230. if (selectedIndex == null) {
  231. const centerOffset = window.innerHeight / 2;
  232. let lastProximity = Infinity;
  233. for (let i = 0; i < media.length; i++) {
  234. const rect = media[i].container.getBoundingClientRect();
  235. let proximity = Math.abs(centerOffset - rect.top);
  236.  
  237. if (rect.top > centerOffset) {
  238. selectedIndex = lastProximity < proximity ? i - 1 : i;
  239. break;
  240. }
  241.  
  242. lastProximity = proximity;
  243. }
  244.  
  245. if (selectedIndex == null && media.length > 0) selectedIndex = media.length - 1;
  246. if (selectedIndex >= 0) setSelectedIndex(selectedIndex);
  247. }
  248.  
  249. function scrollToItem(index, behavior = 'smooth') {
  250. if (listContainer.current?.children[index]) {
  251. scrollToView(listContainer.current.children[index], {block: 'center', behavior});
  252. }
  253. }
  254.  
  255. function selectAndScrollTo(setter) {
  256. setSelectedIndex((index) => {
  257. const nextIndex = typeof setter === 'function' ? setter(index) : setter;
  258. scrollToItem(nextIndex);
  259. return nextIndex;
  260. });
  261. }
  262.  
  263. // If activeIndex changes externally, make sure selectedIndex matches it
  264. useEffect(() => {
  265. if (activeIndex != null && activeIndex != selectedIndex) selectAndScrollTo(activeIndex);
  266. }, [activeIndex]);
  267.  
  268. // Scroll to selected item when list opens
  269. useEffect(() => selectedIndex != null && scrollToItem(selectedIndex, 'auto'), []);
  270.  
  271. // Scroll to the associated post
  272. useEffect(() => {
  273. if (media?.[selectedIndex]?.container && mainContainer.current) {
  274. let offset = getBoundingDocumentRect(mainContainer.current).height;
  275. // scrollToElement(media[selectedIndex].container, offset);
  276. scrollToView(media[selectedIndex].container, {block: offset, behavior: 'smooth'});
  277. }
  278. }, [selectedIndex]);
  279.  
  280. // Keyboard navigation
  281. useKey('w', () => selectAndScrollTo((i) => max(i - itemsPerRow, 0)), [itemsPerRow]);
  282. useKey(
  283. 's',
  284. () => {
  285. selectAndScrollTo((i) => {
  286. // Scroll to the bottom when S is pressed when already at the end of the media list.
  287. // This facilitates clearing new posts notifications.
  288. if (i == media.length - 1) {
  289. document.scrollingElement.scrollTo({
  290. top: document.scrollingElement.scrollHeight,
  291. behavior: 'smooth',
  292. });
  293. }
  294. return min(i + itemsPerRow, media.length - 1);
  295. });
  296. },
  297. [itemsPerRow, media.length]
  298. );
  299. useKey('Home', () => selectAndScrollTo(0), []);
  300. useKey('End', () => selectAndScrollTo(media.length - 1), [media.length]);
  301. useKey('PageUp', () => selectAndScrollTo((i) => max(i - itemsPerRow * 3, 0)), [itemsPerRow]);
  302. useKey('PageDown', () => selectAndScrollTo((i) => min(i + itemsPerRow * 3, media.length)), [
  303. itemsPerRow,
  304. media.length,
  305. ]);
  306. useKey('a', () => selectAndScrollTo((i) => max(i - 1, 0)));
  307. useKey('d', () => selectAndScrollTo((i) => min(i + 1, media.length - 1)), [media.length]);
  308. useKey(
  309. 'W',
  310. () => {
  311. const index = max(selectedIndex - itemsPerRow, 0);
  312. selectAndScrollTo(index);
  313. onActivation(index);
  314. },
  315. [selectedIndex, itemsPerRow]
  316. );
  317. useKey(
  318. 'S',
  319. () => {
  320. // Scroll to the bottom when S is pressed when already at the end of the media list.
  321. // This facilitates clearing new posts notifications.
  322. if (selectedIndex == media.length - 1) {
  323. document.scrollingElement.scrollTo({
  324. top: document.scrollingElement.scrollHeight,
  325. behavior: 'smooth',
  326. });
  327. }
  328. const index = min(selectedIndex + itemsPerRow, media.length - 1);
  329. selectAndScrollTo(index);
  330. onActivation(index);
  331. },
  332. [selectedIndex, itemsPerRow, media.length]
  333. );
  334. useKey(
  335. 'A',
  336. () => {
  337. const prevIndex = max(selectedIndex - 1, 0);
  338. selectAndScrollTo(prevIndex);
  339. onActivation(prevIndex);
  340. },
  341. [selectedIndex]
  342. );
  343. useKey(
  344. 'D',
  345. () => {
  346. const nextIndex = min(selectedIndex + 1, media.length - 1);
  347. selectAndScrollTo(nextIndex);
  348. onActivation(nextIndex);
  349. },
  350. [selectedIndex, media.length]
  351. );
  352. useKey(
  353. 'f',
  354. () => {
  355. onActivation((activeIndex) => (selectedIndex === activeIndex ? null : selectedIndex));
  356. },
  357. [selectedIndex]
  358. );
  359.  
  360. return h('div', {class: cn('Gallery'), ref: mainContainer}, [
  361. h(
  362. 'div',
  363. {class: cn('list'), ref: listContainer},
  364. media.map((item, index) => {
  365. return h(
  366. 'a',
  367. {
  368. key: item.url,
  369. href: item.url,
  370. class: `${selectedIndex === index ? cn('selected') : ''} ${
  371. activeIndex === index ? cn('active') : ''
  372. }`,
  373. onClick: (event) => {
  374. event.preventDefault();
  375. setSelectedIndex(index);
  376. onActivation(index);
  377. },
  378. },
  379. [
  380. h('img', {src: item.thumbnailUrl}),
  381. item.meta && h('span', {class: cn('meta')}, item.meta),
  382. (item.isVideo || item.isGif) && h('div', {class: cn('video-type')}, null, item.extension),
  383. item?.replies > 0 && h('div', {class: cn('replies')}, null, Array(item.replies).fill(h('div'))),
  384. ]
  385. );
  386. })
  387. ),
  388. h('div', {class: cn('meta')}, [
  389. h('div', {class: cn('actions')}, [h('button', {onClick: onToggleHelp}, '? help')]),
  390. h('div', {class: cn('position')}, [
  391. h('span', {class: cn('current')}, selectedIndex + 1),
  392. h('span', {class: cn('separator')}, '/'),
  393. h('span', {class: cn('total')}, media.length),
  394. ]),
  395. ]),
  396. ]);
  397. }
  398.  
  399. function MediaView({item}) {
  400. const containerElement = useRef(null);
  401. const mediaElement = useRef(null);
  402. const [error, setError] = useState(null);
  403. const [displaySpinner, setDisplaySpinner] = useState(true);
  404.  
  405. // Zoom in on Tab down
  406. useKey(
  407. 'Tab',
  408. (event) => {
  409. event.preventDefault();
  410. if (event.repeat) return;
  411. containerElement.current.classList.add(cn('expanded'));
  412.  
  413. // double the size of tiny videos (fill less than half of available space)
  414. const video = mediaElement.current;
  415. if (
  416. video?.nodeName === 'VIDEO' &&
  417. video.videoWidth < window.innerWidth / 2 &&
  418. video.videoHeight < window.innerHeight / 2
  419. ) {
  420. const windowAspectRatio = window.innerWidth / window.innerHeight;
  421. const videoAspectRatio = video.videoWidth / video.videoHeight;
  422. let newHeight, newWidth;
  423. if (windowAspectRatio > videoAspectRatio) {
  424. newHeight = min(video.videoHeight * 2, round(window.innerHeight * 0.8));
  425. newWidth = round(video.videoWidth * (newHeight / video.videoHeight));
  426. } else {
  427. newWidth = min(video.videoWidth * 2, round(window.innerWidth * 0.8));
  428. newHeight = round(video.videoHeight * (newWidth / video.videoWidth));
  429. }
  430. video.style = `width:${newWidth}px;height:${newHeight}px`;
  431. }
  432. },
  433. []
  434. );
  435.  
  436. // Zoom out (restore) on Tab up
  437. useKeyUp(
  438. 'Tab',
  439. (event) => {
  440. containerElement.current.classList.remove(cn('expanded'));
  441. // clean up size doubling of tiny videos
  442. mediaElement.current.style = '';
  443. },
  444. []
  445. );
  446.  
  447. // Initialize new item
  448. useEffect(() => {
  449. setDisplaySpinner(true);
  450. setError(null);
  451. }, [item]);
  452.  
  453. // 100% zoom + dragging on mousedown for images
  454. const handleMouseDown = useCallback(
  455. (event) => {
  456. if (event.which !== 1 || item.isVideo) return;
  457.  
  458. event.preventDefault();
  459. event.stopPropagation();
  460.  
  461. const zoomMargin = 10;
  462. const image = mediaElement.current;
  463. const previewRect = image.getBoundingClientRect();
  464. const zoomFactor = image.naturalWidth / previewRect.width;
  465. const cursorAnchorX = previewRect.left + previewRect.width / 2;
  466. const cursorAnchorY = previewRect.top + previewRect.height / 2;
  467.  
  468. containerElement.current.classList.add(cn('expanded'));
  469.  
  470. const availableWidth = containerElement.current.clientWidth;
  471. const availableHeight = containerElement.current.clientHeight;
  472.  
  473. const dragWidth = max((previewRect.width - availableWidth / zoomFactor) / 2, 0);
  474. const dragHeight = max((previewRect.height - availableHeight / zoomFactor) / 2, 0);
  475.  
  476. const translateWidth = max((image.naturalWidth - availableWidth) / 2, 0);
  477. const translateHeight = max((image.naturalHeight - availableHeight) / 2, 0);
  478.  
  479. Object.assign(image.style, {
  480. maxWidth: 'none',
  481. maxHeight: 'none',
  482. width: 'auto',
  483. height: 'auto',
  484. position: 'fixed',
  485. top: '50%',
  486. left: '50%',
  487. });
  488.  
  489. handleMouseMove(event);
  490.  
  491. function handleMouseMove(event) {
  492. const dragFactorX = dragWidth > 0 ? -((event.clientX - cursorAnchorX) / dragWidth) : 0;
  493. const dragFactorY = dragHeight > 0 ? -((event.clientY - cursorAnchorY) / dragHeight) : 0;
  494. const left = round(
  495. min(max(dragFactorX * translateWidth, -translateWidth - zoomMargin), translateWidth + zoomMargin)
  496. );
  497. const top = round(
  498. min(max(dragFactorY * translateHeight, -translateHeight - zoomMargin), translateHeight + zoomMargin)
  499. );
  500. image.style.transform = `translate(-50%, -50%) translate(${left}px, ${top}px)`;
  501. }
  502.  
  503. function handleMouseUp() {
  504. containerElement.current.classList.remove(cn('expanded'));
  505. image.style = '';
  506. window.removeEventListener('mouseup', handleMouseUp);
  507. window.removeEventListener('mousemove', handleMouseMove);
  508. }
  509.  
  510. window.addEventListener('mouseup', handleMouseUp);
  511. window.addEventListener('mousemove', handleMouseMove);
  512. },
  513. [item]
  514. );
  515.  
  516. return h('div', {class: cn('MediaView'), ref: containerElement, onMouseDown: handleMouseDown}, [
  517. displaySpinner && h('div', {class: cn('spinner-wrapper')}, h(Spinner)),
  518. error
  519. ? h(MediaViewError, {message: error.message || 'Error loading media'})
  520. : h(item.isVideo ? MediaViewVideo : MediaViewImage, {
  521. key: item.url,
  522. url: item.url,
  523. mediaRef: mediaElement,
  524. onReady: () => setDisplaySpinner(false),
  525. onError: (error) => {
  526. setDisplaySpinner(false);
  527. setError(error);
  528. },
  529. }),
  530. ]);
  531. }
  532.  
  533. function MediaViewImage({url, mediaRef, onReady, onError}) {
  534. const imageRef = mediaRef || useRef(null);
  535.  
  536. useEffect(() => {
  537. const intervalId = setInterval(() => {
  538. if (imageRef.current?.naturalWidth > 0) {
  539. onReady();
  540. clearInterval(intervalId);
  541. }
  542. }, 50);
  543.  
  544. return () => clearInterval(intervalId);
  545. }, [url]);
  546.  
  547. return h('img', {class: cn('MediaViewImage'), ref: imageRef, onError, src: url});
  548. }
  549.  
  550. function MediaViewVideo({url, mediaRef, onReady, onError}) {
  551. const [volume, setVolume] = useState(storage.volume);
  552. const containerRef = useRef(null);
  553. const videoRef = mediaRef || useRef(null);
  554. const volumeRef = useRef(null);
  555. const hasAudio = videoRef.current?.audioTracks?.length > 0 || videoRef.current?.mozHasAudio;
  556.  
  557. function playPause() {
  558. if (videoRef.current.paused || videoRef.current.ended) videoRef.current.play();
  559. else videoRef.current.pause();
  560. }
  561.  
  562. useEffect(() => (storage.volume = volume), [volume]);
  563.  
  564. // Video controls and settings synchronization
  565. useEffect(() => {
  566. const container = containerRef.current;
  567. const video = videoRef.current;
  568. const volume = volumeRef.current;
  569.  
  570. function handleStorageSync(prop, value) {
  571. if (prop === 'volume') setVolume(value);
  572. }
  573. function handleClick(event) {
  574. if (event.target !== container && event.target !== video) return;
  575.  
  576. playPause();
  577.  
  578. // Fullscreen toggle on double click
  579. if (event.detail === 2) {
  580. if (!document.fullscreenElement) {
  581. container.requestFullscreen().catch((error) => {
  582. console.log(`Error when enabling full-screen mode: ${error.message} (${error.name})`);
  583. });
  584. } else {
  585. document.exitFullscreen();
  586. }
  587. }
  588. }
  589. function handleVolumeMouseDown(event) {
  590. if (event.which !== 1) return;
  591.  
  592. const pointerTimelineSeek = throttle((mouseEvent) => {
  593. let {top, height} = getBoundingDocumentRect(volume);
  594. let pos = min(max(1 - (mouseEvent.pageY - top) / height, 0), 1);
  595. setVolume(round(pos / CONFIG.adjustVolumeBy) * CONFIG.adjustVolumeBy);
  596. }, 100);
  597.  
  598. function unbind() {
  599. window.removeEventListener('mousemove', pointerTimelineSeek);
  600. window.removeEventListener('mouseup', unbind);
  601. }
  602.  
  603. window.addEventListener('mousemove', pointerTimelineSeek);
  604. window.addEventListener('mouseup', unbind);
  605.  
  606. pointerTimelineSeek(event);
  607. }
  608. function handleContainerWheel(event) {
  609. event.preventDefault();
  610. event.stopPropagation();
  611. setVolume((volume) => min(max(volume + CONFIG.adjustVolumeBy * (event.deltaY > 0 ? -1 : 1), 0), 1));
  612. }
  613.  
  614. const intervalId = setInterval(() => {
  615. if (video.videoHeight > 0) {
  616. onReady();
  617. clearInterval(intervalId);
  618. }
  619. }, 50);
  620.  
  621. function handleError(error) {
  622. onError(error);
  623. clearInterval(intervalId);
  624. }
  625.  
  626. storage.syncListeners.add(handleStorageSync);
  627. video.addEventListener('error', handleError);
  628. container.addEventListener('click', handleClick);
  629. container.addEventListener('wheel', handleContainerWheel);
  630. volume?.addEventListener('mousedown', handleVolumeMouseDown);
  631.  
  632. video.play();
  633.  
  634. return () => {
  635. clearInterval(intervalId);
  636. storage.syncListeners.delete(handleStorageSync);
  637. video.removeEventListener('error', handleError);
  638. container.removeEventListener('click', handleClick);
  639. container.removeEventListener('wheel', handleContainerWheel);
  640. volume?.removeEventListener('mousedown', handleVolumeMouseDown);
  641. };
  642. }, [url]);
  643.  
  644. const flashVolume = useMemo(() => {
  645. let timeoutId;
  646. return () => {
  647. if (timeoutId) clearTimeout(timeoutId);
  648. volumeRef.current.style.opacity = 1;
  649. timeoutId = setTimeout(() => {
  650. volumeRef.current.style = '';
  651. }, 400);
  652. };
  653. }, [volumeRef.current]);
  654.  
  655. useKey(' ', playPause);
  656. useKey('shift+ ', (event) => {
  657. if (videoRef.current && !event.repeat) videoRef.current.playbackRate = 5;
  658. });
  659. useKeyUp('shift+ ', () => {
  660. if (videoRef.current) videoRef.current.playbackRate = 1;
  661. });
  662. useKey('Q', () => {
  663. setVolume((volume) => max(volume - CONFIG.adjustVolumeBy, 0));
  664. flashVolume();
  665. });
  666. useKey('E', () => {
  667. setVolume((volume) => min(volume + CONFIG.adjustVolumeBy, 1));
  668. flashVolume();
  669. });
  670. useKey('q', () => {
  671. const video = videoRef.current;
  672. video.currentTime = max(video.currentTime - CONFIG.seekBy, 0);
  673. });
  674. useKey('e', () => {
  675. const video = videoRef.current;
  676. video.currentTime = min(video.currentTime + CONFIG.seekBy, video.duration);
  677. });
  678.  
  679. // Time navigation by numbers, 1=10%, 5=50%, ... 0=0%
  680. for (let key of [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]) {
  681. useKey(String(key), () => {
  682. if (videoRef.current?.duration > 0) videoRef.current.currentTime = videoRef.current.duration * (key / 10);
  683. });
  684. }
  685.  
  686. return h('div', {class: cn('MediaViewVideo'), ref: containerRef}, [
  687. h('video', {
  688. src: url,
  689. ref: videoRef,
  690. autoplay: false,
  691. preload: false,
  692. controls: false,
  693. loop: true,
  694. volume: volume,
  695. }),
  696. h(VideoTimeline, {videoRef}),
  697. h(
  698. 'div',
  699. {
  700. class: cn('volume'),
  701. ref: volumeRef,
  702. style: hasAudio ? 'display: hidden' : '',
  703. },
  704. h('div', {
  705. class: cn('bar'),
  706. style: `height: ${Number(volume) * 100}%`,
  707. })
  708. ),
  709. ]);
  710. }
  711.  
  712. function VideoTimeline({videoRef}) {
  713. const [state, setState] = useState({progress: 0, elapsed: 0, remaining: 0, duration: 0});
  714. const [bufferedRanges, setBufferedRanges] = useState([]);
  715. const timelineRef = useRef(null);
  716.  
  717. // Video controls and settings synchronization
  718. useEffect(() => {
  719. const video = videoRef.current;
  720. const timeline = timelineRef.current;
  721.  
  722. function handleTimeupdate() {
  723. setState({
  724. progress: video.currentTime / video.duration,
  725. elapsed: video.currentTime,
  726. remaining: video.duration - video.currentTime,
  727. duration: video.duration,
  728. });
  729. }
  730.  
  731. function handleMouseDown(event) {
  732. if (event.which !== 1) return;
  733.  
  734. const pointerTimelineSeek = throttle((mouseEvent) => {
  735. let {left, width} = getBoundingDocumentRect(timeline);
  736. let pos = min(max((mouseEvent.pageX - left) / width, 0), 1);
  737. video.currentTime = pos * video.duration;
  738. }, 100);
  739.  
  740. function unbind() {
  741. window.removeEventListener('mousemove', pointerTimelineSeek);
  742. window.removeEventListener('mouseup', unbind);
  743. }
  744.  
  745. window.addEventListener('mousemove', pointerTimelineSeek);
  746. window.addEventListener('mouseup', unbind);
  747.  
  748. pointerTimelineSeek(event);
  749. }
  750.  
  751. function handleWheel(event) {
  752. event.preventDefault();
  753. event.stopPropagation();
  754. video.currentTime = video.currentTime + 5 * (event.deltaY > 0 ? 1 : -1);
  755. }
  756.  
  757. function handleProgress() {
  758. const buffer = video.buffered;
  759. const duration = video.duration;
  760. const ranges = [];
  761.  
  762. for (let i = 0; i < buffer.length; i++) {
  763. ranges.push({
  764. start: buffer.start(i) / duration,
  765. end: buffer.end(i) / duration,
  766. });
  767. }
  768.  
  769. setBufferedRanges(ranges);
  770. }
  771.  
  772. // `progress` event doesn't fire properly for some reason. Majority of videos get a single `progress`
  773. // event when `video.buffered` ranges are not yet initialized (useless), than another event when
  774. // buffered ranges are at like 3%, and than another event when ranges didn't change from before,
  775. // and that's it... no event for 100% done loading, nothing. I've tried debugging this for hours
  776. // with no success. The only solution is to just interval it until we detect the video is fully loaded.
  777. const progressInterval = setInterval(() => {
  778. handleProgress();
  779. // clear interval when done loading - this is a naive check that doesn't account for missing middle parts
  780. if (video.buffered.length > 0 && video.buffered.end(video.buffered.length - 1) == video.duration) {
  781. clearInterval(progressInterval);
  782. }
  783. }, 500);
  784. // video.addEventListener('progress', handleProgress);
  785.  
  786. video.addEventListener('timeupdate', handleTimeupdate);
  787. timeline.addEventListener('wheel', handleWheel);
  788. timeline.addEventListener('mousedown', handleMouseDown);
  789.  
  790. return () => {
  791. // video.removeEventListener('progress', handleProgress);
  792. video.removeEventListener('timeupdate', handleTimeupdate);
  793. timeline.removeEventListener('wheel', handleWheel);
  794. timeline.removeEventListener('mousedown', handleMouseDown);
  795. };
  796. }, []);
  797.  
  798. const elapsedTime = formatSeconds(state.elapsed);
  799. const totalTime = formatSeconds(CONFIG.totalTime ? state.duration : state.remaining);
  800.  
  801. return h('div', {class: cn('timeline'), ref: timelineRef}, [
  802. ...bufferedRanges.map(({start, end}) =>
  803. h('div', {
  804. class: cn('buffered-range'),
  805. style: {
  806. left: `${start * 100}%`,
  807. right: `${100 - end * 100}%`,
  808. },
  809. })
  810. ),
  811. h('div', {class: cn('elapsed')}, elapsedTime),
  812. h('div', {class: cn('total')}, totalTime),
  813. h('div', {class: cn('progress'), style: `width: ${state.progress * 100}%`}, [
  814. h('div', {class: cn('elapsed')}, elapsedTime),
  815. h('div', {class: cn('total')}, totalTime),
  816. ]),
  817. ]);
  818. }
  819.  
  820. function MediaViewError({message = 'Error'}) {
  821. return h('div', {class: cn('MediaViewError')}, message);
  822. }
  823.  
  824. function Spinner() {
  825. return h('div', {class: cn('Spinner')});
  826. }
  827.  
  828. function useItemsPerRow(ref, dependencies) {
  829. let [itemsPerRow, setItemsPerRow] = useState(1);
  830.  
  831. useEffect(() => {
  832. if (!ref.current?.children[0]) return;
  833. setItemsPerRow(floor(ref.current.clientWidth / ref.current.children[0].offsetWidth));
  834. }, [...dependencies, ref.current]);
  835.  
  836. return itemsPerRow;
  837. }
  838.  
  839. function useWindowDimensions() {
  840. let [dimensions, setDimensions] = useState([window.innerWidth, window.innerHeight]);
  841.  
  842. useEffect(() => {
  843. let timeoutID;
  844. let handleResize = () => {
  845. if (timeoutID) clearTimeout(timeoutID);
  846. timeoutID = setTimeout(() => setDimensions([window.innerWidth, window.innerHeight], 100));
  847. };
  848. window.addEventListener('resize', handleResize);
  849. return () => window.removeEventListener('resize', handleResize);
  850. }, []);
  851.  
  852. return dimensions;
  853. }
  854.  
  855. function useThreadMedia(watcher) {
  856. let [media, setMedia] = useState(watcher.media);
  857.  
  858. useEffect(() => {
  859. let updateMedia = (_, media) => setMedia(media);
  860. watcher.onChange.add(updateMedia);
  861.  
  862. return () => watcher.onChange.delete(updateMedia);
  863. }, [watcher]);
  864.  
  865. return media;
  866. }
  867.  
  868. let handlersByShortcut = {
  869. keydown: new Map(),
  870. keyup: new Map(),
  871. };
  872. function triggerHandlers(event) {
  873. // @ts-ignore
  874. if (INTERACTIVE[event.target.nodeName]) return;
  875. let key = String(event.key);
  876. let shortcutName = '';
  877. if (event.altKey) shortcutName += 'alt';
  878. if (event.ctrlKey) shortcutName += shortcutName.length > 0 ? '+ctrl' : 'ctrl';
  879. // This condition tries to identify keys that have no alternative input when pressing shift
  880. if (event.shiftKey && (key === ' ' || key.length > 1)) shortcutName += shortcutName.length > 0 ? '+shift' : 'shift';
  881. shortcutName += (shortcutName.length > 0 ? '+' : '') + key;
  882.  
  883. let handlers = handlersByShortcut[event.type].get(shortcutName);
  884. if (handlers?.length > 0) {
  885. event.preventDefault();
  886. handlers[handlers.length - 1](event);
  887. }
  888. }
  889.  
  890. window.addEventListener('keydown', triggerHandlers);
  891. window.addEventListener('keyup', triggerHandlers);
  892.  
  893. function _useKey(event, shortcut, handler, dependencies = []) {
  894. useEffect(() => {
  895. if (!shortcut) return;
  896. let handlers = handlersByShortcut[event].get(shortcut);
  897. if (!handlers) {
  898. handlers = [];
  899. handlersByShortcut[event].set(shortcut, handlers);
  900. }
  901. handlers.push(handler);
  902. return () => {
  903. let indexOfHandler = handlers.indexOf(handler);
  904. if (indexOfHandler >= 0) handlers.splice(indexOfHandler, 1);
  905. };
  906. }, [shortcut, ...dependencies]);
  907. }
  908. function useKey(shortcut, handler, dependencies) {
  909. _useKey('keydown', shortcut, handler, dependencies);
  910. }
  911. function useKeyUp(shortcut, handler, dependencies) {
  912. _useKey('keyup', shortcut, handler, dependencies);
  913. }
  914.  
  915. function mediaWatcher(website) {
  916. const watcher = {
  917. website: website,
  918. media: [],
  919. mediaByURL: new Map(),
  920. onChange: new Set(),
  921. threadContainer: document.querySelector(website.threadSelector),
  922. };
  923.  
  924. watcher.serialize = () => {
  925. let media = [...watcher.media];
  926. let addedMedia = [];
  927. let hasNewMedia = false;
  928. let hasChanges = false;
  929.  
  930. for (let element of watcher.threadContainer.querySelectorAll(watcher.website.postSelector)) {
  931. let data = watcher.website.serialize(element);
  932.  
  933. // Ignore items that failed to serialize necessary data
  934. if (data.url == null || data.thumbnailUrl == null) continue;
  935.  
  936. data.extension = String(data.url.match(/\.([^.]+)$/)?.[1] || '').toLowerCase();
  937. data.isVideo = !!data.extension.match(/webm|mp4/);
  938. data.isGif = data.extension === 'gif';
  939. data.meta = data?.meta ? data?.meta.replace('x', '×') : null;
  940. let item = {...data, container: element};
  941.  
  942. let existingItem = watcher.mediaByURL.get(data.url);
  943. if (existingItem) {
  944. // Update existing items (stuff like reply counts)
  945. if (JSON.stringify(existingItem) !== JSON.stringify(item)) {
  946. Object.assign(existingItem, item);
  947. hasChanges = true;
  948. }
  949. continue;
  950. }
  951.  
  952. media.push(item);
  953. watcher.mediaByURL.set(data.url, item);
  954. addedMedia.push(item);
  955. hasNewMedia = true;
  956. }
  957.  
  958. watcher.media = media;
  959.  
  960. if (hasNewMedia || hasChanges) {
  961. for (let handler of watcher.onChange.values()) handler(addedMedia, watcher.media);
  962. }
  963. };
  964.  
  965. if (watcher.threadContainer) {
  966. watcher.serialize();
  967. watcher.observer = new MutationObserver(watcher.serialize);
  968. watcher.observer.observe(watcher.threadContainer, {childList: true, subtree: true});
  969. } else {
  970. log('no thread container found');
  971. }
  972.  
  973. return watcher;
  974. }
  975.  
  976. /**
  977. * localStorage wrapper that saves into a namespaced key as json, and provides
  978. * tab synchronization listeners.
  979. * Usage:
  980. * ```
  981. * let storage = syncedStorage('localStorageKey'); // pre-loads
  982. * storage.foo; // retrieve
  983. * storage.foo = 5; // saves to localStorage automatically
  984. * storage.syncListeners.add((prop, newValue, oldValue) => {}); // when other tab changes storage this is called
  985. * storage.syncListeners.delete(fn); // remove listener
  986. * ```
  987. */
  988. function syncedStorage(localStorageKey, defaults = {}, {syncInterval = 1000} = {}) {
  989. let control = {
  990. syncListeners: new Set(),
  991. savingPromise: null,
  992. };
  993. let storage = {...defaults, ...load()};
  994. let proxy = new Proxy(storage, {
  995. get(storage, prop) {
  996. if (control.hasOwnProperty(prop)) return control[prop];
  997. return storage[prop];
  998. },
  999. set(storage, prop, value) {
  1000. storage[prop] = value;
  1001. save();
  1002. return true;
  1003. },
  1004. });
  1005.  
  1006. setInterval(() => {
  1007. let newData = load();
  1008. for (let key in newData) {
  1009. if (newData[key] !== storage[key]) {
  1010. let oldValue = storage[key];
  1011. storage[key] = newData[key];
  1012. for (let callback of control.syncListeners.values()) {
  1013. callback(key, newData[key], oldValue);
  1014. }
  1015. }
  1016. }
  1017. }, syncInterval);
  1018.  
  1019. function load() {
  1020. let json = localStorage.getItem(localStorageKey);
  1021. let data;
  1022. try {
  1023. data = JSON.parse(json);
  1024. } catch (error) {
  1025. data = {};
  1026. }
  1027. return data;
  1028. }
  1029.  
  1030. function save() {
  1031. if (control.savingPromise) return control.savingPromise;
  1032. control.savingPromise = new Promise((resolve) =>
  1033. setTimeout(() => {
  1034. localStorage.setItem(localStorageKey, JSON.stringify(storage));
  1035. control.savingPromise = null;
  1036. resolve();
  1037. }, 10)
  1038. );
  1039. return control.savingPromise;
  1040. }
  1041.  
  1042. return proxy;
  1043. }
  1044.  
  1045. /**
  1046. *
  1047. */
  1048. function getBoundingDocumentRect(element) {
  1049. if (!element) return;
  1050. const {width, height, top, left, bottom, right} = element.getBoundingClientRect();
  1051. return {
  1052. width,
  1053. height,
  1054. top: window.scrollY + top,
  1055. left: window.scrollX + left,
  1056. bottom: window.scrollY + bottom,
  1057. right: window.scrollX + right,
  1058. };
  1059. }
  1060.  
  1061. function scrollToElement(element, offset = 0, smooth = true) {
  1062. document.scrollingElement.scrollTo({
  1063. top: getBoundingDocumentRect(element).top - offset,
  1064. behavior: smooth ? 'smooth' : 'auto',
  1065. });
  1066. }
  1067.  
  1068. /**
  1069. * Because native `element.scrollIntoView()` is broken in Chrome.
  1070. * I'm having SO MUCH FUN PATCHING STUPID FUCKING DOM BUGS!
  1071. * - `block` can also be an offset from top
  1072. */
  1073. function scrollToView(element, {block = 'start', behavior = 'auto'} = {}) {
  1074. if (!document.body.contains(element)) return;
  1075.  
  1076. let container = element.parentElement;
  1077.  
  1078. while (container) {
  1079. if (isScrollableY(container)) break;
  1080. else container = container.parentElement;
  1081. }
  1082.  
  1083. if (!container) return;
  1084.  
  1085. const containerRect = container.getBoundingClientRect();
  1086. const elementRect = element.getBoundingClientRect();
  1087.  
  1088. const topOffset = Math.abs(elementRect.top - containerRect.top) + (container === document.scrollingElement ? 0 : container.scrollTop);
  1089. const requestedOffset = typeof block === 'number' ? topOffset - block : ({
  1090. start: () => topOffset,
  1091. center: () => topOffset - container.clientHeight / 2 + element.offsetHeight / 2,
  1092. end: () => topOffset - container.clientHeight + element.offsetHeight,
  1093. })[block]();
  1094.  
  1095. container.scrollTo({top: requestedOffset, behavior});
  1096.  
  1097. return requestedOffset;
  1098. }
  1099.  
  1100. /**
  1101. * It's insane that in 2020 there still isn't a reliable way how to check
  1102. * if element is scrollable. Fuck DOM, and fuck web browsers.
  1103. */
  1104. function isScrollableY(element) {
  1105. if (element.scrollHeight === element.clientHeight) return false;
  1106. if (getComputedStyle(element).overflowY === 'hidden') return false;
  1107. if (element.scrollTop > 0) return true;
  1108. element.scrollTop = 1;
  1109. if (element.scrollTop > 0) {
  1110. element.scrollTop = 0;
  1111. return true;
  1112. }
  1113. return false;
  1114. }
  1115.  
  1116. function formatSeconds(seconds) {
  1117. let minutes = floor(seconds / 60);
  1118. let leftover = round(seconds - minutes * 60);
  1119. // @ts-ignore
  1120. return `${String(minutes).padStart(2, 0)}:${String(leftover).padStart(2, 0)}`;
  1121. }
  1122.  
  1123. function throttle(func, wait) {
  1124. var ctx, args, rtn, timeoutID; // caching
  1125. var last = 0;
  1126.  
  1127. return function throttled() {
  1128. ctx = this;
  1129. args = arguments;
  1130. var delta = Date.now() - last;
  1131. if (!timeoutID)
  1132. if (delta >= wait) call();
  1133. else timeoutID = setTimeout(call, wait - delta);
  1134. return rtn;
  1135. };
  1136.  
  1137. function call() {
  1138. timeoutID = 0;
  1139. last = +new Date();
  1140. rtn = func.apply(ctx, args);
  1141. ctx = null;
  1142. args = null;
  1143. }
  1144. }
  1145.  
  1146. // @ts-ignore
  1147. GM_addStyle(`
  1148. /* Scrollbars in chrome since it doesn't support scrollbar-width */
  1149. .${cn('Gallery')} > .${cn('list')}::-webkit-scrollbar,
  1150. .${cn('Help')}::-webkit-scrollbar {
  1151. width: 10px;
  1152. background-color: transparent;
  1153. }
  1154. .${cn('Gallery')} > .${cn('list')}::-webkit-scrollbar-track,
  1155. .${cn('Help')}::-webkit-scrollbar-track {
  1156. border: 0;
  1157. background-color: transparent;6F6F70
  1158. }
  1159. .${cn('Gallery')} > .${cn('list')}::-webkit-scrollbar-thumb,
  1160. .${cn('Help')}::-webkit-scrollbar-thumb {
  1161. border: 0;
  1162. background-color: #6f6f70;
  1163. }
  1164.  
  1165. /* Media Browser */
  1166. .${cn('MediaBrowser')},
  1167. .${cn('MediaBrowser')} *,
  1168. .${cn('MediaBrowser')} *:before,
  1169. .${cn('MediaBrowser')} *:after {box-sizing: border-box;}
  1170. .${cn('MediaBrowser')} {
  1171. --media-list-width: 640px;
  1172. --media-list-height: 50vh;
  1173. --grid-spacing: 5px;
  1174. position: fixed;
  1175. top: 0;
  1176. left: 0;
  1177. width: 100%;
  1178. height: 0;
  1179. font-size: 16px;
  1180. color: #aaa;
  1181. }
  1182.  
  1183. .${cn('Help')} {
  1184. position: fixed;
  1185. bottom: 0;
  1186. left: 0;
  1187. width: var(--media-list-width);
  1188. height: var(--media-list-height);
  1189. padding: 1em 1.5em;
  1190. background: #111;
  1191. color: #aaa;
  1192. overflow: auto;
  1193. scrollbar-width: thin;
  1194. }
  1195. .${cn('Help')} .${cn('close')} {
  1196. position: sticky;
  1197. top: 0; left: 10px;
  1198. float: right;
  1199. margin: 0 -.5em 0 0;
  1200. padding: 0 .3em;
  1201. background: transparent;
  1202. border: 0;
  1203. font-size: 2em !important;
  1204. color: #eee;
  1205. }
  1206. .${cn('Help')} h2 { font-size: 1.2em !important; font-weight: bold; }
  1207. .${cn('Help')} ul { list-style: none; padding-left: 1em; }
  1208. .${cn('Help')} li { padding: .1em 0; }
  1209. .${cn('Help')} code {
  1210. padding: 0 .2em;
  1211. font-weight: bold;
  1212. color: #222;
  1213. border-radius: 2px;
  1214. background: #eee;
  1215. }
  1216. .${cn('Help')} dt { font-weight: bold; }
  1217. .${cn('Help')} dd { margin: .1em 0 .8em; color: #888; }
  1218.  
  1219. .${cn('Gallery')} {
  1220. --item-width: 200px;
  1221. --item-height: 160px;
  1222. --item-border-size: 2px;
  1223. --item-meta-height: 18px;
  1224. --list-meta-height: 22px;
  1225. --active-color: #fff;
  1226. position: absolute;
  1227. top: 0;
  1228. left: 0;
  1229. display: grid;
  1230. grid-template-columns: 1fr;
  1231. grid-template-rows: 1fr var(--list-meta-height);
  1232. width: var(--media-list-width);
  1233. height: var(--media-list-height);
  1234. background: #111;
  1235. box-shadow: 0px 0px 0 3px #0003;
  1236. }
  1237. .${cn('Gallery')} > .${cn('list')} {
  1238. display: grid;
  1239. grid-template-columns: repeat(auto-fit, minmax(var(--item-width), 1fr));
  1240. grid-auto-rows: var(--item-height);
  1241. grid-gap: var(--grid-spacing);
  1242. padding: var(--grid-spacing);
  1243. overflow-y: scroll;
  1244. overflow-x: hidden;
  1245. scrollbar-width: thin;
  1246. }
  1247. .${cn('Gallery')} > .${cn('list')} > a {
  1248. position: relative;
  1249. display: block;
  1250. background: none;
  1251. border: var(--item-border-size) solid transparent;
  1252. padding: 0;
  1253. background: #222;
  1254. outline: none;
  1255. }
  1256. .${cn('Gallery')} > .${cn('list')} > a.${cn('active')} {
  1257. border-color: var(--active-color);
  1258. background: var(--active-color);
  1259. }
  1260. .${cn('Gallery')} > .${cn('list')} > a.${cn('selected')}:after {
  1261. content: '';
  1262. display: block;
  1263. box-sizing: border-box;
  1264. position: absolute;
  1265. left: 50%;
  1266. top: 50%;
  1267. transform: translate(-50%, -50%);
  1268. width: calc(100% + 10px);
  1269. height: calc(100% + 10px);
  1270. border: 2px solid #fff;
  1271. pointer-events: none;
  1272. }
  1273. .${cn('Gallery')} > .${cn('list')} > a > img {
  1274. display: block;
  1275. width: 100%;
  1276. height: calc(var(--item-height) - var(--item-meta-height) - (var(--item-border-size) * 2));
  1277. object-fit: contain;
  1278. border-radius: 2px;
  1279. }
  1280. .${cn('Gallery')} > .${cn('list')} > a > .${cn('meta')} {
  1281. position: absolute;
  1282. bottom: 0;
  1283. left: 0;
  1284. width: 100%;
  1285. height: var(--item-meta-height);
  1286. display: flex;
  1287. align-items: center;
  1288. justify-content: center;
  1289. color: #fff;
  1290. font-size: calc(var(--item-meta-height) * 0.73) !important;
  1291. line-height: 1;
  1292. background: #0003;
  1293. text-shadow: 1px 1px #0003, -1px -1px #0003, 1px -1px #0003, -1px 1px #0003,
  1294. 0px 1px #0003, 0px -1px #0003, 1px 0px #0003, -1px 0px #0003;
  1295. white-space: nowrap;
  1296. overflow: hidden;
  1297. pointer-events: none;
  1298. }
  1299. .${cn('Gallery')} > .${cn('list')} > a.${cn('active')} > .${cn('meta')} {
  1300. color: #222;
  1301. text-shadow: none;
  1302. background: #0001;
  1303. }
  1304. .${cn('Gallery')} > .${cn('list')} > a > .${cn('video-type')} {
  1305. position: absolute;
  1306. top: 50%;
  1307. left: 50%;
  1308. transform: translate(-50%, -50%);
  1309. padding: .5em .5em;
  1310. font-size: 12px !important;
  1311. text-transform: uppercase;
  1312. font-weight: bold;
  1313. line-height: 1;
  1314. color: #222;
  1315. background: #eeeeee88;
  1316. border-radius: 3px;
  1317. border: 1px solid #0000002e;
  1318. background-clip: padding-box;
  1319. pointer-events: none;
  1320. }
  1321. .${cn('Gallery')} > .${cn('list')} > a > .${cn('replies')} {
  1322. position: absolute;
  1323. bottom: calc(var(--item-meta-height) + 2px);
  1324. left: 0;
  1325. width: 100%;
  1326. display: flex;
  1327. justify-content: center;
  1328. flex-wrap: wrap-reverse;
  1329. }
  1330. .${cn('Gallery')} > .${cn('list')} > a > .${cn('replies')} > div {
  1331. width: 6px;
  1332. height: 6px;
  1333. margin: 1px;
  1334. background: var(--active-color);
  1335. background-clip: padding-box;
  1336. border: 1px solid #0008;
  1337. }
  1338. .${cn('Gallery')} > .${cn('meta')} {
  1339. display: grid;
  1340. grid-template-columns: 1fr auto;
  1341. grid-template-rows: 1fr;
  1342. }
  1343. .${cn('Gallery')} > .${cn('meta')} > * {
  1344. display: flex;
  1345. align-items: center;
  1346. font-size: calc(var(--list-meta-height) * 0.7) !important;
  1347. margin: 0 .3em;
  1348. }
  1349. .${cn('Gallery')} > .${cn('meta')} > .${cn('actions')} > button,
  1350. .${cn('Gallery')} > .${cn('meta')} > .${cn('actions')} > button:active {
  1351. color: #eee;
  1352. background: #333;
  1353. border: 0;
  1354. outline: 0;
  1355. border-radius: 2px;
  1356. }
  1357. .${cn('Gallery')} > .${cn('meta')} > .${cn('position')} > .${cn('current')} {
  1358. font-weight: bold;
  1359. }
  1360. .${cn('Gallery')} > .${cn('meta')} > .${cn('position')} > .${cn('separator')} {
  1361. font-size: 1.1em !important;
  1362. margin: 0 0.15em;
  1363. }
  1364.  
  1365. .${cn('MediaView')} {
  1366. position: absolute;
  1367. top: 0; right: 0;
  1368. max-width: calc(100% - var(--media-list-width));
  1369. max-height: 100vh;
  1370. display: flex;
  1371. flex-direction: column;
  1372. align-items: center;
  1373. align-content: center;
  1374. justify-content: center;
  1375. }
  1376. .${cn('MediaView')} > * {
  1377. max-width: 100%;
  1378. max-height: 100vh;
  1379. }
  1380. .${cn('MediaView')}.${cn('expanded')} {
  1381. max-width: 100%;
  1382. width: 100vw; height: 100vh;
  1383. background: #000d;
  1384. z-index: 1000;
  1385. }
  1386. .${cn('MediaView')}.${cn('expanded')} > .${cn('MediaViewVideo')} {
  1387. width: 100%; height: 100%;
  1388. }
  1389.  
  1390. .${cn('MediaView')} > .${cn('spinner-wrapper')} {
  1391. align-self: flex-end;
  1392. flex: 0 0 auto;
  1393. width: 200px;
  1394. height: 200px;
  1395. display: flex;
  1396. align-items: center;
  1397. justify-content: center;
  1398. font-size: 30px !important;
  1399. background: #18181c;
  1400. }
  1401.  
  1402. .${cn('MediaView')} > .${cn('spinner-wrapper')} + * { visibility: hidden; }
  1403.  
  1404. .${cn('MediaViewImage')} { display: block; }
  1405.  
  1406. .${cn('MediaViewVideo')} {
  1407. --timeline-max-size: 40px;
  1408. --timeline-min-size: 20px;
  1409. position: relative;
  1410. display: flex;
  1411. max-width: 100%;
  1412. max-height: 100vh;
  1413. align-items: center;
  1414. justify-content: center;
  1415. }
  1416. .${cn('MediaViewVideo')} > video {
  1417. display: block;
  1418. max-width: 100%;
  1419. max-height: calc(100vh - var(--timeline-min-size));
  1420. margin: 0 auto var(--timeline-min-size);
  1421. outline: none;
  1422. background: #000d;
  1423. }
  1424. .${cn('MediaViewVideo')} > .${cn('timeline')} {
  1425. position: absolute;
  1426. left: 0; bottom: 0;
  1427. width: 100%;
  1428. height: var(--timeline-max-size);
  1429. font-size: 14px !important;
  1430. line-height: 1;
  1431. color: #eee;
  1432. background: #111c;
  1433. border: 1px solid #111c;
  1434. transition: height 100ms ease-out;
  1435. user-select: none;
  1436. }
  1437. .${cn('MediaViewVideo')}:not(:hover) > .${cn('timeline')},
  1438. .${cn('MediaViewVideo')}.${cn('zoomed')} > .${cn('timeline')} {
  1439. height: var(--timeline-min-size);
  1440. }
  1441. .${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('buffered-range')} {
  1442. position: absolute;
  1443. bottom: 0;
  1444. height: 100%;
  1445. background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAFUlEQVQImWNgQAL/////TyqHgYEBAB5CD/FVFp/QAAAAAElFTkSuQmCC') left bottom repeat;
  1446. opacity: .17;
  1447. transition: right 200ms ease-out;
  1448. }
  1449. .${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('progress')} {
  1450. height: 100%;
  1451. background: #eee;
  1452. clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
  1453. }
  1454. .${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('elapsed')},
  1455. .${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('total')} {
  1456. position: absolute;
  1457. top: 0;
  1458. height: 100%;
  1459. display: flex;
  1460. justify-content: center;
  1461. align-items: center;
  1462. padding: 0 .2em;
  1463. 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;
  1464. pointer-events: none;
  1465. }
  1466. .${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('elapsed')} {left: 0;}
  1467. .${cn('MediaViewVideo')} > .${cn('timeline')} .${cn('total')} {right: 0;}
  1468. .${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('progress')} .${cn('elapsed')},
  1469. .${cn('MediaViewVideo')} > .${cn('timeline')} > .${cn('progress')} .${cn('total')} {
  1470. color: #111;
  1471. text-shadow: none;
  1472. }
  1473.  
  1474. .${cn('MediaViewVideo')} > .${cn('volume')} {
  1475. position: absolute;
  1476. right: 10px;
  1477. top: calc(25% - var(--timeline-min-size));
  1478. width: 30px;
  1479. height: 50%;
  1480. background: #111c;
  1481. border: 1px solid #111c;
  1482. transition: opacity 100ms linear;
  1483. }
  1484. .${cn('MediaViewVideo')}:not(:hover) > .${cn('volume')} {opacity: 0;}
  1485. .${cn('MediaViewVideo')} > .${cn('volume')} > .${cn('bar')} {
  1486. position: absolute;
  1487. left: 0;
  1488. bottom: 0;
  1489. width: 100%;
  1490. background: #eee;
  1491. }
  1492.  
  1493. .${cn('MediaViewError')} {
  1494. display: flex;
  1495. align-items: center;
  1496. justify-content: center;
  1497. min-width: 400px;
  1498. min-height: 300px;
  1499. padding: 2em 2.5em;
  1500. background: #a34;
  1501. color: ##fff;
  1502. }
  1503.  
  1504. .${cn('Spinner')} {
  1505. width: 1.6em;
  1506. height: 1.6em;
  1507. animation: Spinner-rotate 500ms infinite linear;
  1508. border: 0.1em solid #fffa;
  1509. border-right-color: #1d1f21aa;
  1510. border-left-color: #1d1f21aa;
  1511. border-radius: 50%;
  1512. }
  1513.  
  1514. @keyframes Spinner-rotate {
  1515. 0% { transform: rotate(0deg); }
  1516. 100% { transform: rotate(360deg); }
  1517. }
  1518. `);