Reddit Search Preview Inline Interactive Gallery Carousel

Interactive Inline Gallery Carousel with full-res Images for Reddit Search Gallery.

目前为 2025-02-10 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Reddit Search Preview Inline Interactive Gallery Carousel
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.3
  5. // @description Interactive Inline Gallery Carousel with full-res Images for Reddit Search Gallery.
  6. // @author UniverseDev
  7. // @license GPL-3.0-or-later
  8. // @icon https://www.reddit.com/favicon.ico
  9. // @match *://*.reddit.com/search/*type=media*
  10. // @grant GM_addStyle
  11. // @grant GM.xmlHttpRequest
  12. // ==/UserScript==
  13.  
  14. (() => {
  15. 'use strict';
  16.  
  17. GM_addStyle(`
  18. .reddit-carousel {
  19. position: relative;
  20. overflow: hidden;
  21. width: 100%;
  22. height: 100%;
  23. cursor: default;
  24. z-index: 1;
  25. }
  26. .reddit-carousel-slide-container {
  27. display: flex;
  28. transition: transform 125ms ease;
  29. z-index: 1;
  30. }
  31. .reddit-carousel-slide {
  32. flex: 0 0 100%;
  33. width: 100%;
  34. text-align: center;
  35. display: flex;
  36. justify-content: center;
  37. align-items: center;
  38. z-index: 1;
  39. }
  40. .reddit-carousel-slide img {
  41. max-height: 100vw;
  42. height: 100%;
  43. width: 100%;
  44. object-fit: contain;
  45. object-position: center center;
  46. margin: 0 auto;
  47. z-index: 1;
  48. }
  49. .reddit-carousel-error {
  50. color: red;
  51. font-size: 14px;
  52. padding: 20px;
  53. z-index: 1;
  54. }
  55. .reddit-carousel-arrow {
  56. position: absolute;
  57. top: 50%;
  58. transform: translateY(-50%);
  59. background: rgba(0,0,0,0.7);
  60. border: none;
  61. width: 30px;
  62. height: 30px;
  63. cursor: pointer;
  64. border-radius: 50%;
  65. display: flex;
  66. align-items: center;
  67. justify-content: center;
  68. z-index: 2;
  69. transition: background 0.2s ease, transform 0.15s ease;
  70. color: #fff;
  71. box-shadow: 0 2px 4px rgba(0,0,0,0.4);
  72. pointer-events: auto;
  73. }
  74. .reddit-carousel-arrow:hover {
  75. background: rgba(0,0,0,0.85);
  76. transform: translateY(-50%) scale(1.1);
  77. box-shadow: 0 4px 8px rgba(0,0,0,0.5);
  78. }
  79. .reddit-carousel-arrow:active {
  80. transform: translateY(-50%) scale(0.95);
  81. }
  82. .reddit-carousel-arrow svg {
  83. width: 16px;
  84. height: 16px;
  85. fill: currentColor;
  86. }
  87. .reddit-carousel-arrow.left { left: 10px; display: none; }
  88. .reddit-carousel-arrow.right { right: 10px; display: flex; }
  89. `);
  90.  
  91. const redditCarousel_animateTransition = (container, start, end, duration, callback) => {
  92. const startTime = performance.now();
  93. const step = now => {
  94. const progress = Math.min((now - startTime) / duration, 1);
  95. const ease = 1 - Math.pow(1 - progress, 3);
  96. container.style.transform = `translateX(${start + (end - start) * ease}px)`;
  97. if (progress < 1) requestAnimationFrame(step);
  98. else if (callback) callback();
  99. };
  100. requestAnimationFrame(step);
  101. };
  102.  
  103. function adjustSlideHeight(slide) {
  104. const img = slide.querySelector('img');
  105. if (!img) return;
  106. if (!img.complete || img.naturalWidth === 0) {
  107. img.addEventListener('load', () => adjustSlideHeight(slide));
  108. return;
  109. }
  110. const aspectRatio = img.naturalWidth / img.naturalHeight;
  111. const slideWidth = slide.clientWidth;
  112. slide.style.height = (slideWidth / aspectRatio) + 'px';
  113. }
  114.  
  115. function adjustAllSlideHeights() {
  116. document.querySelectorAll('.reddit-carousel-slide').forEach(slide => {
  117. adjustSlideHeight(slide);
  118. });
  119. centerImagesWithBlackBackground();
  120. }
  121.  
  122. // New helper function: set a black background and enforce centering for slides
  123. function centerImagesWithBlackBackground() {
  124. document.querySelectorAll('.reddit-carousel-slide').forEach(slide => {
  125. slide.style.backgroundColor = 'black';
  126. slide.style.display = 'flex';
  127. slide.style.justifyContent = 'center';
  128. slide.style.alignItems = 'center';
  129. });
  130. }
  131.  
  132. const redditCarousel_createCarousel = (primaryUrls, fallbackUrls, mediaMeta, altText) => {
  133. if (!primaryUrls?.length) return null;
  134. const carousel = document.createElement('div');
  135. carousel.classList.add('reddit-carousel');
  136. carousel.addEventListener('click', e => {
  137. e.stopPropagation();
  138. e.preventDefault();
  139. });
  140. const slideContainer = document.createElement('div');
  141. slideContainer.classList.add('reddit-carousel-slide-container');
  142. carousel.appendChild(slideContainer);
  143. primaryUrls.forEach((url, i) => {
  144. const slide = document.createElement('div');
  145. slide.classList.add('reddit-carousel-slide');
  146. const img = document.createElement('img');
  147. img.src = url;
  148. img.alt = altText;
  149. if (fallbackUrls && fallbackUrls[i]) {
  150. img.dataset.fallback = fallbackUrls[i];
  151. }
  152. img.onerror = function() {
  153. if (this.dataset.fallback && this.src !== this.dataset.fallback) {
  154. this.src = this.dataset.fallback;
  155. } else {
  156. slide.innerHTML = '<div class="reddit-carousel-error">Image failed to load</div>';
  157. }
  158. };
  159. img.addEventListener('load', () => {
  160. redditCarousel_recalcDimensions();
  161. redditCarousel_updateArrowVisibility();
  162. updateArrowPositions();
  163. adjustSlideHeight(slide);
  164. });
  165. slide.appendChild(img);
  166. slideContainer.appendChild(slide);
  167. });
  168.  
  169. const carouselCounterWrapper = document.createElement('div');
  170. carouselCounterWrapper.innerHTML = `<div class="absolute inset-0 overflow-visible flex items-right justify-end">
  171. <button rpl="" class="pointer-events-none m-xs leading-4 pl-2xs pr-2xs py-0 text-sm h-fit button-small px-[var(--rem10)] button-media items-center justify-center button inline-flex ">
  172. <span class="flex items-center justify-center">
  173. <span class="carousel-counter flex items-center gap-xs">1/${primaryUrls.length}</span>
  174. </span>
  175. </button>
  176. </div>`;
  177. carousel.appendChild(carouselCounterWrapper);
  178.  
  179. let redditCarousel_currentIndex = 0, redditCarousel_currentOffset = 0;
  180. const redditCarousel_recalcDimensions = () => {
  181. const containerWidth = slideContainer.clientWidth;
  182. redditCarousel_currentOffset = -redditCarousel_currentIndex * containerWidth;
  183. slideContainer.style.transform = `translateX(${redditCarousel_currentOffset}px)`;
  184. };
  185. const redditCarousel_updateCounter = () => {
  186. const newCounterText = `${redditCarousel_currentIndex + 1}/${primaryUrls.length}`;
  187. const carouselCounter = carousel.querySelector('.carousel-counter');
  188. if (carouselCounter) {
  189. carouselCounter.textContent = newCounterText;
  190. }
  191. };
  192. const redditCarousel_goToSlide = index => {
  193. index = Math.max(0, Math.min(index, primaryUrls.length - 1));
  194. const containerWidth = slideContainer.clientWidth;
  195. const startOffset = redditCarousel_currentOffset;
  196. const endOffset = -index * containerWidth;
  197. redditCarousel_animateTransition(slideContainer, startOffset, endOffset, 125, () => {
  198. redditCarousel_currentOffset = endOffset;
  199. redditCarousel_currentIndex = index;
  200. redditCarousel_updateCounter();
  201. redditCarousel_updateArrowVisibility();
  202. updateArrowPositions();
  203. });
  204. };
  205. let redditCarousel_leftArrow, redditCarousel_rightArrow;
  206. if (primaryUrls.length > 1) {
  207. const redditCarousel_createArrow = dir => {
  208. const btn = document.createElement('button');
  209. btn.classList.add('reddit-carousel-arrow', dir);
  210. btn.addEventListener('click', e => {
  211. e.stopPropagation();
  212. e.preventDefault();
  213. });
  214. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  215. svg.setAttribute("viewBox", "0 0 20 20");
  216. const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  217. path.setAttribute("d", "M12.793 19.707l-9-9a1 1 0 0 1 0-1.414l9-9 1.414 1.414L5.914 10l8.293 8.293-1.414 1.414Z");
  218. svg.appendChild(path);
  219. if (dir === 'right') {
  220. svg.style.transform = 'scaleX(-1)';
  221. }
  222. btn.appendChild(svg);
  223. return btn;
  224. };
  225. redditCarousel_leftArrow = redditCarousel_createArrow('left');
  226. redditCarousel_leftArrow.addEventListener('click', () => {
  227. redditCarousel_goToSlide(redditCarousel_currentIndex - 1);
  228. });
  229. carousel.appendChild(redditCarousel_leftArrow);
  230. redditCarousel_rightArrow = redditCarousel_createArrow('right');
  231. redditCarousel_rightArrow.addEventListener('click', () => {
  232. redditCarousel_goToSlide(redditCarousel_currentIndex + 1);
  233. });
  234. carousel.appendChild(redditCarousel_rightArrow);
  235. }
  236. const redditCarousel_updateArrowVisibility = () => {
  237. if (primaryUrls.length <= 1) return;
  238. if (redditCarousel_currentIndex === 0) {
  239. redditCarousel_leftArrow.style.display = 'none';
  240. redditCarousel_rightArrow.style.display = 'flex';
  241. } else if (redditCarousel_currentIndex === primaryUrls.length - 1) {
  242. redditCarousel_leftArrow.style.display = 'flex';
  243. redditCarousel_rightArrow.style.display = 'none';
  244. } else {
  245. redditCarousel_leftArrow.style.display = 'flex';
  246. redditCarousel_rightArrow.style.display = 'flex';
  247. }
  248. };
  249. const updateArrowPositions = () => {
  250. requestAnimationFrame(() => {
  251. const rect = carousel.getBoundingClientRect();
  252. const arrowTop = (rect.height - 30) / 2;
  253. if (redditCarousel_leftArrow) {
  254. redditCarousel_leftArrow.style.top = arrowTop + 'px';
  255. }
  256. if (redditCarousel_rightArrow) {
  257. redditCarousel_rightArrow.style.top = arrowTop + 'px';
  258. }
  259. });
  260. };
  261. updateArrowPositions();
  262. let redditCarousel_resizeTimeout;
  263. window.addEventListener('resize', () => {
  264. clearTimeout(redditCarousel_resizeTimeout);
  265. redditCarousel_resizeTimeout = setTimeout(() => {
  266. redditCarousel_recalcDimensions();
  267. redditCarousel_updateArrowVisibility();
  268. updateArrowPositions();
  269. adjustAllSlideHeights();
  270. }, 100);
  271. });
  272. redditCarousel_updateArrowVisibility();
  273. return carousel;
  274. };
  275.  
  276. const redditCarousel_fetchAndProcessGallery = (postURL, container) => {
  277. GM.xmlHttpRequest({
  278. url: `${postURL}.json`,
  279. method: 'GET',
  280. onload: response => {
  281. if (response.status >= 200 && response.status < 300) {
  282. try {
  283. const jsonData = JSON.parse(response.responseText);
  284. const postData = jsonData[0]?.data?.children[0]?.data;
  285. if (!postData) return;
  286. const altText = postData.title || "Reddit Gallery Image";
  287. let fullResUrls = [];
  288. let previewUrls = [];
  289. let mediaMeta = {};
  290. if (postData?.gallery_data && postData?.media_metadata) {
  291. const { items } = postData.gallery_data;
  292. mediaMeta = postData.media_metadata;
  293. fullResUrls = items.reduce((acc, item) => {
  294. const meta = mediaMeta[item.media_id];
  295. if (meta && meta.id && meta.m) {
  296. let ext = "jpg";
  297. if (meta.m.includes("png")) ext = "png";
  298. else if (meta.m.includes("gif")) ext = "gif";
  299. else if (meta.m.includes("webp")) ext = "webp";
  300. acc.push(`https://i.redd.it/${meta.id}.${ext}`);
  301. }
  302. return acc;
  303. }, []);
  304. }
  305. if (postData.preview && postData.preview.images && postData.preview.images.length > 0) {
  306. previewUrls = postData.preview.images.map(image => {
  307. let url = image.source?.url;
  308. if (url) {
  309. url = url.replace(/&/g, '&amp;');
  310. return url;
  311. }
  312. return null;
  313. }).filter(url => url);
  314. } else if (postData.thumbnail && postData.thumbnail.startsWith('http')) {
  315. previewUrls = [postData.thumbnail];
  316. } else if (postData?.gallery_data && postData?.media_metadata) {
  317. const { items } = postData.gallery_data;
  318. mediaMeta = postData.media_metadata;
  319. previewUrls = items.reduce((acc, item) => {
  320. const meta = mediaMeta[item.media_id];
  321. if (meta && meta.id && meta.p && meta.p.u) {
  322. let url = meta.p.u;
  323. url = url.replace(/&/g, '&amp;');
  324. acc.push(url);
  325. }
  326. return acc;
  327. }, []);
  328. }
  329. let primaryUrls = [];
  330. let fallbackUrls = [];
  331. if (fullResUrls.length > 0) {
  332. primaryUrls = fullResUrls;
  333. fallbackUrls = previewUrls;
  334. } else {
  335. primaryUrls = previewUrls;
  336. }
  337. const carousel = redditCarousel_createCarousel(primaryUrls, fallbackUrls, mediaMeta, altText);
  338. if (carousel) {
  339. const targetElement = container.querySelector('.relative.w-full.h-full');
  340. if (targetElement) {
  341. targetElement.innerHTML = '';
  342. targetElement.appendChild(carousel);
  343. const parentLink = targetElement.closest('a');
  344. if (parentLink) {
  345. parentLink.addEventListener('click', e => {
  346. e.stopPropagation();
  347. e.preventDefault();
  348. });
  349. }
  350. }
  351. }
  352. } catch (error) {
  353. console.error("JSON parse error:", error);
  354. }
  355. }
  356. },
  357. onerror: err => console.error("Request failed:", err)
  358. });
  359. };
  360.  
  361. const redditCarousel_galleryObserver = new IntersectionObserver((entries, observer) => {
  362. entries.forEach(entry => {
  363. if (entry.isIntersecting) {
  364. const container = entry.target;
  365. if (!container.hasAttribute('data-gallery-intersected')) {
  366. container.setAttribute('data-gallery-intersected', 'true');
  367. const postUnit = container.closest('div[data-id="search-media-post-unit"]');
  368. const postLink = postUnit?.querySelector('a.no-underline');
  369. if (postLink?.href) {
  370. redditCarousel_fetchAndProcessGallery(postLink.href, container);
  371. }
  372. }
  373. observer.unobserve(container);
  374. }
  375. });
  376. }, { threshold: 0.1 });
  377.  
  378. const redditCarousel_processSearchResults = () => {
  379. document.querySelectorAll('div[data-id="search-media-post-unit"]').forEach(post => {
  380. if (post.hasAttribute('data-gallery-checked')) return;
  381. post.setAttribute('data-gallery-checked', 'true');
  382. const indicator = post.querySelector('div.absolute.inset-0.overflow-visible.flex.items-right.justify-end button span');
  383. if (indicator?.textContent.includes('/')) {
  384. const container = post.querySelector('shreddit-aspect-ratio');
  385. if (container) redditCarousel_galleryObserver.observe(container);
  386. }
  387. });
  388. };
  389.  
  390. let redditCarousel_ticking = false;
  391. window.addEventListener('scroll', () => {
  392. if (!redditCarousel_ticking) {
  393. requestAnimationFrame(() => {
  394. redditCarousel_processSearchResults();
  395. redditCarousel_ticking = false;
  396. });
  397. redditCarousel_ticking = true;
  398. }
  399. });
  400. window.addEventListener('load', redditCarousel_processSearchResults);
  401. window.addEventListener('load', adjustAllSlideHeights);
  402. })();