Greasy Fork is available in English.

8chan gallery script

Gallery viewer for 8chan threads

  1. // ==UserScript==
  2. // @name 8chan gallery script
  3. // @namespace https://greatest.deepsurf.us/en/users/1461449
  4. // @match https://8chan.moe/*/res/*
  5. // @match https://8chan.se/*/res/*
  6. // @grant GM_setValue
  7. // @grant GM_getValue
  8. // @version 1.7
  9. // @description Gallery viewer for 8chan threads
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. function addCSS(css) {
  14. const style = document.createElement('style');
  15. document.head.append(style);
  16. style.textContent = css;
  17. return style;
  18. }
  19.  
  20. const options = new Proxy({}, {
  21. get: (_, prop) => {
  22. if (prop == "volume") {
  23. let e = parseFloat(localStorage.getItem('8chan-volume'));
  24. return isNaN(e) ? 0 : e
  25. } else {
  26. return GM_getValue(prop);
  27. }
  28. },
  29. set: (_, prop, value) => {
  30. prop == "volume" ? localStorage.setItem('8chan-volume', value) : GM_setValue(prop, value);
  31. return true;
  32. }
  33. });
  34.  
  35. if (!options.exists) {
  36. options.exists = true;
  37. options.muteVideo = false;
  38. options.volume = 0.3
  39. }
  40.  
  41. if (options.muteVideo) {
  42. options.volume = 0;
  43. }
  44.  
  45. class Post {
  46. constructor(element, thread) {
  47. this.element = element;
  48. this.id = element.id;
  49. this.replies = [];
  50.  
  51. if (thread) {
  52. thread.posts.push(this);
  53. element.querySelectorAll('.postInfo > .panelBacklinks > a').forEach(link => {
  54. const reply = link.textContent.replace(/\D/g, '');
  55. this.replies.push(reply);
  56. });
  57. }
  58.  
  59. const details = element.querySelectorAll('details');
  60. this.files = Array.from(details).map(d => {
  61. const imgLink = d.querySelector('a.imgLink');
  62. if (imgLink) {
  63. return {
  64. url: imgLink.href,
  65. thumbnail: imgLink.querySelector('img')?.src,
  66. name: d.querySelector('.originalNameLink')?.download || '',
  67. video: d.querySelector('video') !== null,
  68. parentPost: this
  69. };
  70. }
  71. }).filter(Boolean);
  72. }
  73. hidden() {
  74. return this.element.querySelector(".unhideButton") !== null;
  75. }
  76. }
  77.  
  78. class Thread extends Post {
  79. static all = [];
  80. constructor(opEl) {
  81. super(opEl, null);
  82. this.posts = [this];
  83. Thread.all.push(this);
  84. }
  85. }
  86.  
  87. class Gallery {
  88. constructor() {
  89. this.visible = false;
  90. this.showImages = true;
  91. this.showVideos = true;
  92. this.currentIndex = 0;
  93. this.rotation = 0;
  94. this.container = null;
  95. this.viewer = null;
  96. this.mediaEl = null;
  97. this.sidebar = null;
  98. this.previewContainer = null;
  99. this.previews = [];
  100.  
  101. document.addEventListener('keyup', e => {
  102. if (e.key === 'g') {
  103. this.visible ? this.remove() : this.show();
  104. } else if (e.key === 'Escape' && this.visible) {
  105. this.remove();
  106. }
  107. });
  108.  
  109. document.addEventListener('keydown', e => {
  110. if (!this.visible) return;
  111. switch (e.key) {
  112. case 'ArrowLeft':
  113. this.showIndex((this.currentIndex - 1 + this.filteredMedia.length) % this.filteredMedia.length);
  114. break;
  115. case 'ArrowRight':
  116. this.showIndex((this.currentIndex + 1) % this.filteredMedia.length);
  117. break;
  118. case 'r':
  119. if (!e.ctrlKey) this.rotate();
  120. break;
  121. }
  122. });
  123. }
  124.  
  125. mediaItems() {
  126. return this.thread.posts.flatMap(p => (p.files || []).filter(f => !p.hidden() && (f.video ? this.showVideos : this.showImages)));
  127. }
  128.  
  129. show() {
  130. if (!this.container) this.buildUI();
  131.  
  132. const op = document.querySelector('div.opCell .innerOP');
  133. if (!op) return;
  134. this.thread = new Thread(op);
  135. document.querySelectorAll('div.opCell .divPosts > div').forEach(el => new Post(el, this.thread));
  136.  
  137. document.body.append(this.container);
  138. this.visible = true;
  139. this.updatePreviews();
  140. this.currentIndex = this.getClosestPost();
  141. this.showIndex(this.currentIndex);
  142. }
  143.  
  144. getClosestPost() {
  145. const centerY = window.innerHeight / 2;
  146. let best = { idx: 0, dist: Infinity };
  147. this.mediaItems().forEach((media, i) => {
  148. const rect = media.parentPost?.element.getBoundingClientRect();
  149. if (rect) {
  150. const postCenter = rect.top + rect.height / 2;
  151. const dist = Math.abs(postCenter - centerY);
  152. if (dist < best.dist) best = { idx: i, dist };
  153. }
  154. });
  155. return best.idx;
  156. }
  157.  
  158. remove() {
  159. if (this.container) this.container.remove();
  160. this.visible = false;
  161. this.mediaEl.onmouseout();
  162. }
  163.  
  164. addMediaScroll(mediaEl) {
  165. let supportsPassive = false;
  166. try {
  167. window.addEventListener("test", null, Object.defineProperty({}, 'passive', {
  168. get: function () { supportsPassive = true; }
  169. }));
  170. } catch (e) { }
  171.  
  172. let wheelOpt = supportsPassive ? { passive: false } : false;
  173. let wheelEvent = 'onwheel' in document.createElement('div') ? 'wheel' : 'mousewheel';
  174.  
  175. function handleScroll(e) {
  176. e.preventDefault();
  177. mediaEl.volume += (e.deltaY < 0 ? 0.02 : -0.02);
  178. mediaEl.volume = Math.min(Math.max(mediaEl.volume, 0), 1);
  179. }
  180.  
  181. mediaEl.onmouseover = () => {
  182. window.addEventListener(wheelEvent, handleScroll, wheelOpt);
  183. };
  184.  
  185. mediaEl.onmouseout = () => {
  186. window.removeEventListener(wheelEvent, handleScroll, wheelOpt);
  187. };
  188. }
  189.  
  190. buildUI() {
  191. this.container = document.createElement('div');
  192. Object.assign(this.container.style, {
  193. position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
  194. background: 'rgba(0,0,0,0.7)', display: 'flex', zIndex: 9999
  195. });
  196.  
  197. this.viewer = document.createElement('div');
  198. Object.assign(this.viewer.style, {
  199. flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative'
  200. });
  201. this.viewer.addEventListener('click', (e) => {
  202. if (e.target === this.viewer) this.remove();
  203. });
  204.  
  205. this.labelsDiv = document.createElement("div");
  206. this.labelsDiv.id = "gallery-labels";
  207.  
  208. let infoLabels = document.createElement("div");
  209. infoLabels.setAttribute("id", "gallery-labels-info");
  210. Object.assign(infoLabels.style, {
  211. position: "absolute", display: "flex", flexDirection: "column",
  212. alignItems: "flex-end", bottom: "5px", right: "5px", borderRadius: "3px", zIndex: "59"
  213. });
  214.  
  215. this.filenameLabel = document.createElement("a");
  216. this.filenameLabel.classList.add("gallery-label");
  217. this.filenameLabel.id = "gallery-label-filename"
  218. this.filenameLabel.style.color = "white";
  219.  
  220. this.indexLabel = document.createElement("a");
  221. this.indexLabel.classList.add("gallery-label");
  222. this.indexLabel.id = "gallery-label-index"
  223. this.indexLabel.style.color = "white";
  224.  
  225. infoLabels.append(this.indexLabel, this.filenameLabel);
  226.  
  227. this.filterLabels = document.createElement("div");
  228. Object.assign(this.filterLabels.style, {
  229. position: "absolute", display: "flex", flexDirection: "column",
  230. alignItems: "flex-end", top: "5px", right: "5px", borderRadius: "3px", zIndex: "59"
  231. });
  232.  
  233. const createFilterLabel = (id, text, toggleFn) => {
  234. const label = document.createElement("a");
  235. label.id = id;
  236. label.textContent = text;
  237. label.classList.add("gallery-label");
  238. label.style.color = "white";
  239. label.style.cursor = 'pointer';
  240. label.addEventListener('click', toggleFn);
  241. return label;
  242. };
  243.  
  244. const imageLabel = createFilterLabel("gallery-label-image", "Images", () => {
  245. this.showImages = !this.showImages;
  246. imageLabel.style.color = this.showImages ? "white" : "red";
  247. const currentMedia = this.filteredMedia[this.currentIndex];
  248. this.updatePreviews();
  249. if (this.filteredMedia) {
  250. let newIndex = this.filteredMedia.indexOf(this.filteredMedia.find(el => el == currentMedia));
  251. if (newIndex == -1) {
  252. newIndex = this.getClosestPost();
  253. this.showIndex(newIndex)
  254. } else {
  255. this.showIndex(newIndex, false);
  256. }
  257. }
  258. });
  259.  
  260. const videoLabel = createFilterLabel("gallery-label-video", "Videos", () => {
  261. this.showVideos = !this.showVideos;
  262. videoLabel.style.color = this.showVideos ? "white" : "red";
  263. const currentMedia = this.filteredMedia[this.currentIndex];
  264. this.updatePreviews();
  265. if (this.filteredMedia) {
  266. let newIndex = this.filteredMedia.indexOf(this.filteredMedia.find(el => el == currentMedia));
  267. if (newIndex == -1) {
  268. newIndex = this.getClosestPost();
  269. }
  270. this.showIndex(newIndex)
  271. }
  272. });
  273.  
  274. this.filterLabels.append(imageLabel, videoLabel);
  275. this.labelsDiv.append(this.filterLabels, infoLabels);
  276. this.viewer.append(this.labelsDiv);
  277.  
  278. this.mediaEl = document.createElement('video');
  279. this.mediaEl.controls = true;
  280. this.mediaEl.loop = true;
  281. this.mediaEl.style.maxWidth = '90vw';
  282. this.mediaEl.style.height = '94vh';
  283. this.mediaEl.style.objectFit = 'contain';
  284. this.mediaEl.addEventListener('volumechange', () => { options.volume = this.mediaEl.volume; });
  285. this.addMediaScroll(this.mediaEl);
  286.  
  287. this.mediaContainer = document.createElement('div');
  288. this.mediaContainer.append(this.mediaEl);
  289. this.viewer.append(this.mediaContainer);
  290.  
  291. this.sidebar = document.createElement('div');
  292. this.sidebar.id = 'gallery-sidebar';
  293. this.sidebar.tabIndex = 0;
  294. this.sidebar.addEventListener('wheel', (e) => {
  295. const delta = e.deltaY;
  296. const atTop = this.sidebar.scrollTop === 0;
  297. const atBottom = this.sidebar.scrollHeight - this.sidebar.clientHeight === this.sidebar.scrollTop;
  298. if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
  299. e.preventDefault();
  300. }
  301. }, { passive: false });
  302. Object.assign(this.sidebar.style, {
  303. width: '150px', background: 'rgba(0,0,0,0.6)', padding: '5px', overflowY: 'auto'
  304. });
  305.  
  306. this.previewContainer = document.createElement('div');
  307. this.sidebar.append(this.previewContainer);
  308. this.container.append(this.viewer, this.sidebar);
  309.  
  310. addCSS(`
  311. /* Explicit color for filter labels to ensure visibility */
  312. #gallery-label-image, #gallery-label-video, #gallery-label-mute { color: white; }
  313. #gallery-label-image:hover, #gallery-label-video:hover, #gallery-label-mute:hover, #gallery-label-filename:hover { background: rgba(50, 50, 50, 0.8) !important; }
  314.  
  315. /* Ensure labels are above media elements */
  316. #gallery-labels { z-index: 10; }
  317.  
  318. #gallery-sidebar { scrollbar-width: thin; scrollbar-color: #555 #222; }
  319. #gallery-sidebar::-webkit-scrollbar { width: 8px; }
  320. #gallery-sidebar::-webkit-scrollbar-track { background: #222; }
  321. #gallery-sidebar::-webkit-scrollbar-thumb { background-color: #555; border-radius: 4px; border: 2px solid #222; }
  322.  
  323. .gallery-thumb { display: block; width: calc(100% - 10px); margin: 0 auto 8px auto; cursor: pointer; opacity: 0.6; transition: opacity 0.2s ease-in-out, border-color 0.2s ease-in-out; border: 2px solid transparent; border-radius: 3px; box-sizing: border-box; background-color: #111; /* Add background for missing thumbs */ min-height: 50px; /* Min height for missing thumbs */ }
  324. .gallery-thumb:hover { opacity: 0.85; }
  325. .gallery-thumb.selected { opacity: 1; border: 2px solid #00baff; }
  326.  
  327. #gallery-labels { pointer-events: none; }
  328. #gallery-labels > div { position: absolute; right: 10px; display: flex; flex-direction: column; align-items: flex-end; pointer-events: auto; }
  329. #gallery-labels-info { bottom: 10px; }
  330. #gallery-labels-filters { top: 10px; }
  331.  
  332. .gallery-label { display: block; padding: 3px 6px; background: rgba(0, 0, 0, 0.7) !important; margin-bottom: 4px; font-size: 0.9em; text-decoration: none; border-radius: 3px; transition: background-color 0.2s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 250px; }
  333. .gallery-filter-label.gallery-label { cursor: pointer; }
  334. #gallery-label-index.gallery-label { user-select: none; cursor: pointer; /* Make index clickable to scroll */ }
  335. #gallery-label-index.gallery-label:hover {cursor: unset !important}
  336. `);
  337. }
  338.  
  339. updatePreviews() {
  340. this.previewContainer.innerHTML = '';
  341. this.filteredMedia = this.mediaItems();
  342. this.previews = [];
  343. this.filteredMedia.forEach((media, idx) => {
  344. const thumb = document.createElement('img');
  345. thumb.src = media.thumbnail;
  346. thumb.title = media.name;
  347. thumb.className = 'gallery-thumb';
  348.  
  349. thumb.addEventListener('click', () => this.showIndex(idx));
  350.  
  351. const wrapper = document.createElement('div');
  352. wrapper.style.position = 'relative';
  353. wrapper.appendChild(thumb);
  354. const replyLen = media.parentPost?.replies?.length || 0;
  355. if (replyLen > 0) {
  356. const replyCount = document.createElement('div');
  357. replyCount.textContent = replyLen;
  358. Object.assign(replyCount.style, {
  359. position: 'absolute',
  360. top: '3px',
  361. right: '3px',
  362. background: 'rgba(0,0,0,0.6)',
  363. color: '#fff',
  364. padding: '2px 5px',
  365. fontSize: '13px',
  366. borderRadius: '3px',
  367. pointerEvents: 'none'
  368. });
  369. wrapper.appendChild(replyCount);
  370. }
  371. this.previewContainer.append(wrapper);
  372. this.previews.push(thumb);
  373. });
  374. }
  375.  
  376. updateLabels(media) {
  377. this.filenameLabel.textContent = media.name;
  378. this.filenameLabel.setAttribute("href", media.url);
  379. this.indexLabel.textContent = (this.currentIndex + 1) + " / " + this.filteredMedia.length;
  380. }
  381.  
  382. showIndex(idx, updateMedia = true) {
  383. this.currentIndex = idx;
  384. this.previews.forEach((t, i) => t.classList.toggle('selected', i === idx));
  385. this.previews[idx].scrollIntoView({ behavior: 'auto', block: 'center' });
  386.  
  387. const media = this.filteredMedia[idx];
  388. Array.from(this.mediaContainer.querySelectorAll('img')).forEach(img => img.remove());
  389.  
  390. this.updateLabels(media);
  391.  
  392. if (updateMedia) {
  393. if (media.video) {
  394. this.mediaEl.style.display = '';
  395. this.mediaEl.src = media.url;
  396. this.mediaEl.volume = options.volume;
  397. this.mediaEl.play().catch(() => {});
  398. } else {
  399. this.mediaEl.pause();
  400. this.mediaEl.style.display = 'none';
  401. const img = document.createElement('img');
  402. img.src = media.url;
  403. img.style.maxWidth = '90vw';
  404. img.style.height = '98vh';
  405. img.style.objectFit = 'contain';
  406. this.mediaContainer.append(img);
  407. }
  408. this.rotation = 0;
  409. this.mediaContainer.style.transform = 'rotate(0deg)';
  410. media.parentPost?.element.scrollIntoView({ behavior: 'auto', block: 'center' });
  411. }
  412. }
  413.  
  414. rotate() {
  415. this.rotation = (this.rotation + 90) % 360;
  416. this.mediaContainer.style.transform = `rotate(${this.rotation}deg)`;
  417. }
  418. }
  419.  
  420. (() => {
  421. const op = document.querySelector('div.opCell .innerOP');
  422. if (!op) return;
  423. new Gallery();
  424. })();