Thread Media Viewer

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

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

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