Reddit Search Preview Inline Interactive Gallery Carousel

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

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

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