Galleria

Sankaku Complex (Idol) Gallery Overlay

  1. // ==UserScript==
  2. // @name Galleria
  3. // @version 0.3.1
  4. // @description Sankaku Complex (Idol) Gallery Overlay
  5. // @homepage https://github.com/agony-central/Galleria
  6. // @supportURL https://github.com/agony-central/Galleria/issues
  7. // @author agony_central
  8. // @include /^https?://idol.sankakucomplex.com/?(\?[^/]+)?$/
  9. // @require https://code.jquery.com/jquery-3.6.2.slim.js
  10. // @require https://unpkg.com/react@18/umd/react.production.min.js
  11. // @require https://unpkg.com/react-dom@18/umd/react-dom.production.min.js
  12. // @icon https://idol.sankakucomplex.com/favicon.png
  13. // @grant unsafeWindow
  14. // @namespace https://greasyfork.org/users/1000369
  15. // ==/UserScript==
  16.  
  17. (async () => {
  18. const LOG_PREFIX = '[Galleria]';
  19. const LOG_LEVEL = 1;
  20. const print = (level, ...args) => {
  21. if (level <= LOG_LEVEL) console.info(LOG_PREFIX, ...args);
  22. };
  23.  
  24. const RESOURCE_PREFIX = '__GALLERIA_';
  25. const rn = (name) => RESOURCE_PREFIX + name;
  26.  
  27. const FETCH_MEDIA_RATE_LIMIT = 5000;
  28. const CHECK_NEW_POST_INTERVAL = 1800;
  29. const CHECK_NEW_PAGE_INTERVAL = 5000;
  30. const LOOKAHEAD_POST_COUNT = 32;
  31.  
  32. const COMMON_CLASSNAME_POST = 'thumb';
  33.  
  34. const { $ } = window;
  35. const { React } = window;
  36. const { ReactDOM } = window;
  37. const e = React.createElement;
  38.  
  39. /**
  40. *
  41. * The page we are on stores all posts in "#content > .content"
  42. * however, the page has infinite scroll pagination.
  43. *
  44. * There are three details to note about the pagination:
  45. *
  46. * First: the post storage ("#content > .content") stores the
  47. * first page as a direct child div, with no ID.
  48. *
  49. * Second: the rest of the pages are stored as siblings of
  50. * the first page, with IDs of the form "content-page-n" where
  51. * `n` is the page number (NOT zero-indexed, starts at 2 since the
  52. * first page has no ID).
  53. *
  54. * Third: the first page's first element is a grouped, promotional
  55. * post with the ID "popular-preview", which we will ignore.
  56. *
  57. * =================================================================
  58. *
  59. * Galleria is concerned with two things:
  60. *
  61. * [UI] overlaying a gallery on the page, that can be:
  62. * a. toggled on and off
  63. * b. navigated easily
  64. *
  65. * [LOADER] keeping an actively loaded list of posts, by:
  66. * a. scrolling to the bottom of the page when appropriate
  67. * 1. when the number of remaining posts ahead of the
  68. * currently viewed post is less than LOOKAHEAD_POST_COUNT
  69. * b. loading the media of posts as they are encountered
  70. * 1. creating an iframe to the post's page
  71. * 2. extracting the media from the iframe
  72. * 3. storing the media in the post's data
  73. *
  74. * =================================================================
  75. */
  76.  
  77. /**
  78. * globalStore
  79. *
  80. * An object that manages the current state of the UI and LOADER
  81. * components of Galleria.
  82. */
  83. const globalStore = {
  84. ui: {
  85. // whether the React app is currently mounted
  86. mounted: false,
  87. // whether the gallery is currently visible
  88. visible: false,
  89. // index of the currently viewed post
  90. currentPostIndex: 0,
  91. },
  92. loader: {
  93. // last encountered page number
  94. lastPage: 0,
  95. // all posts encountered so far
  96. posts: [],
  97. // last time a post's media was loaded
  98. lastFetch: 0,
  99. },
  100. };
  101.  
  102. const ref_elem_Content = $('#content .content');
  103. // const ref_elem_GalleryMediaStore = $(
  104. // `<div id="${rn('media-store')}"></div>`
  105. // );
  106. const ref_elem_GalleryRoot = $(`<div id="${rn('root')}"></div>`).appendTo(
  107. 'body'
  108. );
  109.  
  110. /**
  111. * Current post React component
  112. */
  113. class CurrentPost extends React.Component {
  114. constructor(props) {
  115. super(props);
  116. this.iframeRef = React.createRef();
  117.  
  118. this.pruneIframe = this.pruneIframe.bind(this);
  119. }
  120.  
  121. render() {
  122. return e('iframe', {
  123. ref: this.iframeRef,
  124. src: this.props.data.link,
  125. style: {
  126. boxSizing: 'border-box',
  127. width: '100%',
  128. height: '100%',
  129. margin: 0,
  130. padding: 0,
  131. border: 0,
  132. },
  133. onLoad: this.pruneIframe,
  134. });
  135. }
  136.  
  137. pruneIframe() {
  138. // $(this.iframeRef.current.contentDocument)
  139. // .contents()
  140. // .find('*')
  141. // .not('#image')
  142. // .remove();
  143. }
  144. }
  145.  
  146. /**
  147. * Post preview (thumbnail) React component
  148. */
  149. class PostPreview extends React.Component {
  150. state = {
  151. hover: false,
  152. };
  153.  
  154. constructor(props) {
  155. super(props);
  156. }
  157.  
  158. render() {
  159. return e(
  160. 'div',
  161. {
  162. key: this.props.id,
  163. index: this.props.index,
  164. style: {
  165. position: 'relative',
  166. boxSizing: 'border-box',
  167. width: '100%',
  168. height: '200px',
  169. margin: 0,
  170. padding: 0,
  171. backgroundColor: this.props.active ? 'white' : 'black',
  172. },
  173. onClick: () => this.props.handleClick(this.props.index),
  174. onMouseEnter: () => this.setState({ hover: true }),
  175. onMouseLeave: () => this.setState({ hover: false }),
  176. },
  177. [
  178. e('img', {
  179. src: this.props.src,
  180. style: {
  181. width: '100%',
  182. height: '100%',
  183. margin: 0,
  184. padding: 0,
  185. objectFit: this.state.hover ? 'contain' : 'cover',
  186. },
  187. }),
  188. this.props.active
  189. ? e('div', {
  190. style: {
  191. position: 'absolute',
  192. top: 0,
  193. left: 0,
  194. width: '100%',
  195. height: '100%',
  196. backgroundColor: 'rgba(0, 0, 0, 0.7)',
  197. },
  198. })
  199. : undefined,
  200. ]
  201. );
  202. }
  203. }
  204.  
  205. /**
  206. * Sidebar React component of loaded posts
  207. */
  208. class PostList extends React.Component {
  209. constructor(props) {
  210. super(props);
  211. this.ref_elem_Sidebar = React.createRef();
  212. }
  213.  
  214. render() {
  215. return e(
  216. 'div',
  217. {
  218. ref: this.ref_elem_Sidebar,
  219. style: {
  220. display: 'inline-block',
  221. boxSizing: 'border-box',
  222. overflow: 'hidden scroll',
  223. width: '100%',
  224. height: '100%',
  225. margin: 0,
  226. padding: 0,
  227. },
  228. },
  229. [
  230. this.props.data.map((post, index) =>
  231. e(PostPreview, {
  232. id: post.id,
  233. index,
  234. src: post.thumbnail,
  235. active: index == this.props.currentPostIndex,
  236. handleClick: this.props.handleClick,
  237. })
  238. ),
  239. ]
  240. );
  241. }
  242. }
  243.  
  244. /**
  245. * Root React component
  246. */
  247. class Galleria extends React.Component {
  248. constructor(props) {
  249. super(props);
  250. this.state = { ...props.globalStore };
  251.  
  252. this.syncGlobalStore = this.syncGlobalStore.bind(this);
  253. this.toggleGallery = this.toggleGallery.bind(this);
  254. this.updateCurrentPostIndex =
  255. this.updateCurrentPostIndex.bind(this);
  256. this.viewNextPost = this.viewNextPost.bind(this);
  257. this.viewPreviousPost = this.viewPreviousPost.bind(this);
  258. this.handleKeyDown = this.handleKeyDown.bind(this);
  259. }
  260.  
  261. componentWillMount() {
  262. this.props.ensureMinimumPosts();
  263. document.addEventListener('keydown', this.handleKeyDown);
  264. }
  265.  
  266. componentDidMount() {
  267. globalStore.ui.mounted = true;
  268. this.syncGlobalStore();
  269. }
  270.  
  271. componentWillUnmount() {
  272. globalStore.ui.mounted = false;
  273. document.removeEventListener('keydown', this.handleKeyDown);
  274. }
  275.  
  276. syncGlobalStore() {
  277. this.setState({ ...this.props.globalStore });
  278. }
  279.  
  280. toggleGallery(force = null) {
  281. if (force !== null) {
  282. globalStore.ui.visible = force;
  283. } else {
  284. globalStore.ui.visible = !globalStore.ui.visible;
  285. }
  286.  
  287. this.syncGlobalStore();
  288. }
  289.  
  290. updateCurrentPostIndex(newPostIndex) {
  291. globalStore.ui.currentPostIndex = newPostIndex;
  292. this.syncGlobalStore();
  293.  
  294. this.props.ensureMinimumPosts();
  295. }
  296.  
  297. viewNextPost() {
  298. const nextPostIndex = this.state.ui.currentPostIndex + 1;
  299. if (nextPostIndex >= this.state.loader.posts.length) return;
  300.  
  301. this.updateCurrentPostIndex(nextPostIndex);
  302. }
  303.  
  304. viewPreviousPost() {
  305. const previousPostIndex = this.state.ui.currentPostIndex - 1;
  306. if (previousPostIndex < 0) return;
  307.  
  308. this.updateCurrentPostIndex(previousPostIndex);
  309. }
  310.  
  311. handleKeyDown(e) {
  312. switch (e.key) {
  313. case 'g':
  314. case 'f':
  315. this.toggleGallery();
  316. break;
  317. case 'Escape':
  318. this.toggleGallery(false);
  319. break;
  320. case 'd':
  321. case 's':
  322. case 'ArrowDown':
  323. case 'ArrowRight':
  324. this.viewNextPost();
  325. break;
  326. case 'a':
  327. case 'w':
  328. case 'ArrowUp':
  329. case 'ArrowLeft':
  330. this.viewPreviousPost();
  331. break;
  332. }
  333. }
  334.  
  335. render() {
  336. window.document.body.style.overflowY = this.state.ui.visible
  337. ? 'hidden'
  338. : 'scroll';
  339.  
  340. return e('section', {}, [
  341. this.state.ui.visible
  342. ? e(
  343. 'div',
  344. {
  345. style: {
  346. position: 'fixed',
  347. top: 0,
  348. left: 0,
  349. width: '100vw',
  350. height: 'calc(100vh - 50px)',
  351. zIndex: 10001,
  352. display: 'grid',
  353. gridTemplateColumns: '1fr 4fr',
  354. backgroundColor: 'black',
  355. color: 'white',
  356. },
  357. },
  358. [
  359. e(PostList, {
  360. data: this.state.loader.posts,
  361. currentPostIndex:
  362. this.state.ui.currentPostIndex,
  363. handleClick: this.updateCurrentPostIndex,
  364. }),
  365. e(CurrentPost, {
  366. index: this.state.ui.currentPostIndex,
  367. data: this.state.loader.posts[
  368. this.state.ui.currentPostIndex
  369. ],
  370. loadPost: () =>
  371. this.props.loadPostMediaByIndex(
  372. this.state.ui.currentPostIndex
  373. ),
  374. }),
  375. ]
  376. )
  377. : null,
  378. e(
  379. 'button',
  380. {
  381. onClick: this.toggleGallery,
  382. style: {
  383. boxSizing: 'content-box',
  384. width: '100%',
  385. height: '50px',
  386. margin: 0,
  387. position: 'fixed',
  388. left: 0,
  389. bottom: 0,
  390. zIndex: 10002,
  391. border: 0,
  392. backgroundColor: 'black',
  393. color: 'white',
  394. },
  395. },
  396. 'Toggle Gallery'
  397. ),
  398. ]);
  399. }
  400. }
  401.  
  402. async function loadNewPosts(newPosts) {
  403. let postsLoaded = 0;
  404. for (let i = 0; i < newPosts.length; i++) {
  405. const post = newPosts[i];
  406.  
  407. /**
  408. * Skip elements that don't have the class name
  409. * `COMMON_CLASSNAME_POST`.
  410. */
  411. if (!post.classList.contains(COMMON_CLASSNAME_POST)) continue;
  412.  
  413. /**
  414. * Posts, as they are loaded, start out with an inline style
  415. * of "display: none;" — we iterate over each post, waiting
  416. * for the display to no longer be "none", and then we add
  417. * its ID, link, and thumbnail as an object to
  418. * `globalStore.loader.posts`.
  419. *
  420. * This is technically unnecessary, since the data we need
  421. * is already available in the DOM, but it is a good
  422. * idea to wait for the post to be fully loaded before
  423. * adding it to the globalStore.
  424. */
  425. while (post.style.display === 'none') {
  426. await new Promise((r) =>
  427. setTimeout(r, CHECK_NEW_POST_INTERVAL)
  428. );
  429. }
  430.  
  431. globalStore.loader.posts.push({
  432. id: post.id.substring(1),
  433. link: post.children[0].href,
  434. thumbnail: post.children[0].children[0].src,
  435. });
  436.  
  437. if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore();
  438.  
  439. postsLoaded++;
  440. }
  441.  
  442. print(2, `Loaded ${postsLoaded} new posts.`);
  443. }
  444.  
  445. async function parseNewPages() {
  446. const lastPageInDOM = ref_elem_Content.find('.content-page').last();
  447. const lastPageNumber = lastPageInDOM.length
  448. ? parseInt(lastPageInDOM.attr('id').split('-')[2])
  449. : 1;
  450.  
  451. print(2, 'Last page in DOM:', lastPageNumber);
  452. if (lastPageNumber == 1) return;
  453.  
  454. /**
  455. * Iterate over any new pages, loading their posts.
  456. */
  457. for (
  458. let idx = globalStore.loader.lastPage + 1;
  459. idx <= lastPageNumber;
  460. idx++
  461. ) {
  462. const pageID = `#content-page-${idx}`;
  463. print(2, `Parsing page "${pageID}" [${idx}/${lastPageNumber}]...`);
  464.  
  465. const newPosts = $(pageID).children();
  466. await loadNewPosts(newPosts);
  467.  
  468. globalStore.loader.lastPage = idx;
  469. }
  470. }
  471.  
  472. /**
  473. * This function is called repeatedly to check if new posts
  474. * need to be loaded, and if so, updates the globalStore.
  475. */
  476. async function ensureMinimumPosts() {
  477. print(2, 'Ensuring minimum posts...');
  478.  
  479. /**
  480. * If the `globalStore.loader.lastPage` is 0, then we have not
  481. * yet initialized — so we load the first page while ignoring
  482. * the first post (the promotional post).
  483. */
  484. if (globalStore.loader.lastPage === 0) {
  485. print(2, 'Loading first page...');
  486.  
  487. const newPosts = ref_elem_Content
  488. .children()
  489. .first()
  490. .children()
  491. .slice(1);
  492. await loadNewPosts(newPosts);
  493.  
  494. globalStore.loader.lastPage = 1;
  495. }
  496.  
  497. let prevPostCount = globalStore.loader.posts.length;
  498. while (
  499. prevPostCount - globalStore.ui.currentPostIndex <
  500. LOOKAHEAD_POST_COUNT
  501. ) {
  502. print(2, 'Scrolling to next "page" for more posts...');
  503. unsafeWindow.scrollTo(0, unsafeWindow.document.body.scrollHeight);
  504.  
  505. // wait a split second to let the page load
  506. await new Promise((r) => setTimeout(r, 450));
  507.  
  508. await parseNewPages();
  509. if (prevPostCount === globalStore.loader.posts.length) {
  510. // should have waited longer
  511. print(
  512. 1,
  513. 'Haven\'t found new page yet, waiting before trying again...'
  514. );
  515. await new Promise((r) =>
  516. setTimeout(r, CHECK_NEW_PAGE_INTERVAL)
  517. );
  518. } else {
  519. prevPostCount = globalStore.loader.posts.length;
  520. }
  521. }
  522. }
  523.  
  524. async function loadPostMediaByIndex(index) {
  525. if (
  526. Date.now() - globalStore.loader.lastMediaLoad <
  527. FETCH_MEDIA_RATE_LIMIT
  528. ) {
  529. print(2, 'Media load rate limit exceeded');
  530. const timeRemaining =
  531. FETCH_MEDIA_RATE_LIMIT -
  532. (Date.now() - globalStore.loader.lastMediaLoad);
  533. await new Promise((r) => setTimeout(r, timeRemaining));
  534. }
  535.  
  536. const post = globalStore.loader.posts[index];
  537. if (!post) {
  538. print(1, 'No post found for index', index);
  539. return;
  540. }
  541.  
  542. if (post.fullMedia) {
  543. print(2, 'Media already loaded for post', index);
  544. return;
  545. }
  546.  
  547. print(2, 'Loading media for post', index);
  548. // use jquery to create iframe to post.link
  549. // then use jquery to find media in iframe
  550. // then use jquery to remove iframe
  551.  
  552. const iframe = $(`<iframe src="${post.link}"></iframe>`);
  553. iframe.css('display', 'none');
  554. $('body').append(iframe);
  555.  
  556. let iframeLoading = true;
  557. iframe.on('load', () => {
  558. iframeLoading = false;
  559. });
  560.  
  561. print(2, 'Waiting for iframe to load...');
  562. await new Promise((r) => setTimeout(r, 2000));
  563. while (iframeLoading) {
  564. print(2, 'Still waiting for iframe to load...');
  565. await new Promise((r) => setTimeout(r, 2000));
  566. }
  567.  
  568. const media = iframe.contents().find('#image')[0];
  569. print(2, 'iframe loaded with media:', media);
  570. post.fullMedia = iframe;
  571.  
  572. globalStore.loader.lastMediaLoad = Date.now();
  573. if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore();
  574. }
  575.  
  576. /**
  577. * Initialize React application
  578. */
  579. const __GALLERIA = ReactDOM.render(
  580. e(Galleria, {
  581. globalStore,
  582. ensureMinimumPosts,
  583. loadPostMediaByIndex,
  584. }),
  585. ref_elem_GalleryRoot[0]
  586. );
  587. })();