// ==UserScript==
// @name Galleria
// @version 0.3.1
// @description Sankaku Complex (Idol) Gallery Overlay
// @homepage https://github.com/agony-central/Galleria
// @supportURL https://github.com/agony-central/Galleria/issues
// @author agony_central
// @include /^https?://idol.sankakucomplex.com/?(\?[^/]+)?$/
// @require https://code.jquery.com/jquery-3.6.2.slim.js
// @require https://unpkg.com/react@18/umd/react.production.min.js
// @require https://unpkg.com/react-dom@18/umd/react-dom.production.min.js
// @icon https://idol.sankakucomplex.com/favicon.png
// @grant unsafeWindow
// @namespace https://greasyfork.org/users/1000369
// ==/UserScript==
(async () => {
const LOG_PREFIX = '[Galleria]';
const LOG_LEVEL = 1;
const print = (level, ...args) => {
if (level <= LOG_LEVEL) console.info(LOG_PREFIX, ...args);
};
const RESOURCE_PREFIX = '__GALLERIA_';
const rn = (name) => RESOURCE_PREFIX + name;
const FETCH_MEDIA_RATE_LIMIT = 5000;
const CHECK_NEW_POST_INTERVAL = 1800;
const CHECK_NEW_PAGE_INTERVAL = 5000;
const LOOKAHEAD_POST_COUNT = 32;
const COMMON_CLASSNAME_POST = 'thumb';
const { $ } = window;
const { React } = window;
const { ReactDOM } = window;
const e = React.createElement;
/**
*
* The page we are on stores all posts in "#content > .content"
* however, the page has infinite scroll pagination.
*
* There are three details to note about the pagination:
*
* First: the post storage ("#content > .content") stores the
* first page as a direct child div, with no ID.
*
* Second: the rest of the pages are stored as siblings of
* the first page, with IDs of the form "content-page-n" where
* `n` is the page number (NOT zero-indexed, starts at 2 since the
* first page has no ID).
*
* Third: the first page's first element is a grouped, promotional
* post with the ID "popular-preview", which we will ignore.
*
* =================================================================
*
* Galleria is concerned with two things:
*
* [UI] overlaying a gallery on the page, that can be:
* a. toggled on and off
* b. navigated easily
*
* [LOADER] keeping an actively loaded list of posts, by:
* a. scrolling to the bottom of the page when appropriate
* 1. when the number of remaining posts ahead of the
* currently viewed post is less than LOOKAHEAD_POST_COUNT
* b. loading the media of posts as they are encountered
* 1. creating an iframe to the post's page
* 2. extracting the media from the iframe
* 3. storing the media in the post's data
*
* =================================================================
*/
/**
* globalStore
*
* An object that manages the current state of the UI and LOADER
* components of Galleria.
*/
const globalStore = {
ui: {
// whether the React app is currently mounted
mounted: false,
// whether the gallery is currently visible
visible: false,
// index of the currently viewed post
currentPostIndex: 0,
},
loader: {
// last encountered page number
lastPage: 0,
// all posts encountered so far
posts: [],
// last time a post's media was loaded
lastFetch: 0,
},
};
const ref_elem_Content = $('#content .content');
// const ref_elem_GalleryMediaStore = $(
// `<div id="${rn('media-store')}"></div>`
// );
const ref_elem_GalleryRoot = $(`<div id="${rn('root')}"></div>`).appendTo(
'body'
);
/**
* Current post React component
*/
class CurrentPost extends React.Component {
constructor(props) {
super(props);
this.iframeRef = React.createRef();
this.pruneIframe = this.pruneIframe.bind(this);
}
render() {
return e('iframe', {
ref: this.iframeRef,
src: this.props.data.link,
style: {
boxSizing: 'border-box',
width: '100%',
height: '100%',
margin: 0,
padding: 0,
border: 0,
},
onLoad: this.pruneIframe,
});
}
pruneIframe() {
// $(this.iframeRef.current.contentDocument)
// .contents()
// .find('*')
// .not('#image')
// .remove();
}
}
/**
* Post preview (thumbnail) React component
*/
class PostPreview extends React.Component {
state = {
hover: false,
};
constructor(props) {
super(props);
}
render() {
return e(
'div',
{
key: this.props.id,
index: this.props.index,
style: {
position: 'relative',
boxSizing: 'border-box',
width: '100%',
height: '200px',
margin: 0,
padding: 0,
backgroundColor: this.props.active ? 'white' : 'black',
},
onClick: () => this.props.handleClick(this.props.index),
onMouseEnter: () => this.setState({ hover: true }),
onMouseLeave: () => this.setState({ hover: false }),
},
[
e('img', {
src: this.props.src,
style: {
width: '100%',
height: '100%',
margin: 0,
padding: 0,
objectFit: this.state.hover ? 'contain' : 'cover',
},
}),
this.props.active
? e('div', {
style: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
})
: undefined,
]
);
}
}
/**
* Sidebar React component of loaded posts
*/
class PostList extends React.Component {
constructor(props) {
super(props);
this.ref_elem_Sidebar = React.createRef();
}
render() {
return e(
'div',
{
ref: this.ref_elem_Sidebar,
style: {
display: 'inline-block',
boxSizing: 'border-box',
overflow: 'hidden scroll',
width: '100%',
height: '100%',
margin: 0,
padding: 0,
},
},
[
this.props.data.map((post, index) =>
e(PostPreview, {
id: post.id,
index,
src: post.thumbnail,
active: index == this.props.currentPostIndex,
handleClick: this.props.handleClick,
})
),
]
);
}
}
/**
* Root React component
*/
class Galleria extends React.Component {
constructor(props) {
super(props);
this.state = { ...props.globalStore };
this.syncGlobalStore = this.syncGlobalStore.bind(this);
this.toggleGallery = this.toggleGallery.bind(this);
this.updateCurrentPostIndex =
this.updateCurrentPostIndex.bind(this);
this.viewNextPost = this.viewNextPost.bind(this);
this.viewPreviousPost = this.viewPreviousPost.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
}
componentWillMount() {
this.props.ensureMinimumPosts();
document.addEventListener('keydown', this.handleKeyDown);
}
componentDidMount() {
globalStore.ui.mounted = true;
this.syncGlobalStore();
}
componentWillUnmount() {
globalStore.ui.mounted = false;
document.removeEventListener('keydown', this.handleKeyDown);
}
syncGlobalStore() {
this.setState({ ...this.props.globalStore });
}
toggleGallery(force = null) {
if (force !== null) {
globalStore.ui.visible = force;
} else {
globalStore.ui.visible = !globalStore.ui.visible;
}
this.syncGlobalStore();
}
updateCurrentPostIndex(newPostIndex) {
globalStore.ui.currentPostIndex = newPostIndex;
this.syncGlobalStore();
this.props.ensureMinimumPosts();
}
viewNextPost() {
const nextPostIndex = this.state.ui.currentPostIndex + 1;
if (nextPostIndex >= this.state.loader.posts.length) return;
this.updateCurrentPostIndex(nextPostIndex);
}
viewPreviousPost() {
const previousPostIndex = this.state.ui.currentPostIndex - 1;
if (previousPostIndex < 0) return;
this.updateCurrentPostIndex(previousPostIndex);
}
handleKeyDown(e) {
switch (e.key) {
case 'g':
case 'f':
this.toggleGallery();
break;
case 'Escape':
this.toggleGallery(false);
break;
case 'd':
case 's':
case 'ArrowDown':
case 'ArrowRight':
this.viewNextPost();
break;
case 'a':
case 'w':
case 'ArrowUp':
case 'ArrowLeft':
this.viewPreviousPost();
break;
}
}
render() {
window.document.body.style.overflowY = this.state.ui.visible
? 'hidden'
: 'scroll';
return e('section', {}, [
this.state.ui.visible
? e(
'div',
{
style: {
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: 'calc(100vh - 50px)',
zIndex: 10001,
display: 'grid',
gridTemplateColumns: '1fr 4fr',
backgroundColor: 'black',
color: 'white',
},
},
[
e(PostList, {
data: this.state.loader.posts,
currentPostIndex:
this.state.ui.currentPostIndex,
handleClick: this.updateCurrentPostIndex,
}),
e(CurrentPost, {
index: this.state.ui.currentPostIndex,
data: this.state.loader.posts[
this.state.ui.currentPostIndex
],
loadPost: () =>
this.props.loadPostMediaByIndex(
this.state.ui.currentPostIndex
),
}),
]
)
: null,
e(
'button',
{
onClick: this.toggleGallery,
style: {
boxSizing: 'content-box',
width: '100%',
height: '50px',
margin: 0,
position: 'fixed',
left: 0,
bottom: 0,
zIndex: 10002,
border: 0,
backgroundColor: 'black',
color: 'white',
},
},
'Toggle Gallery'
),
]);
}
}
async function loadNewPosts(newPosts) {
let postsLoaded = 0;
for (let i = 0; i < newPosts.length; i++) {
const post = newPosts[i];
/**
* Skip elements that don't have the class name
* `COMMON_CLASSNAME_POST`.
*/
if (!post.classList.contains(COMMON_CLASSNAME_POST)) continue;
/**
* Posts, as they are loaded, start out with an inline style
* of "display: none;" — we iterate over each post, waiting
* for the display to no longer be "none", and then we add
* its ID, link, and thumbnail as an object to
* `globalStore.loader.posts`.
*
* This is technically unnecessary, since the data we need
* is already available in the DOM, but it is a good
* idea to wait for the post to be fully loaded before
* adding it to the globalStore.
*/
while (post.style.display === 'none') {
await new Promise((r) =>
setTimeout(r, CHECK_NEW_POST_INTERVAL)
);
}
globalStore.loader.posts.push({
id: post.id.substring(1),
link: post.children[0].href,
thumbnail: post.children[0].children[0].src,
});
if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore();
postsLoaded++;
}
print(2, `Loaded ${postsLoaded} new posts.`);
}
async function parseNewPages() {
const lastPageInDOM = ref_elem_Content.find('.content-page').last();
const lastPageNumber = lastPageInDOM.length
? parseInt(lastPageInDOM.attr('id').split('-')[2])
: 1;
print(2, 'Last page in DOM:', lastPageNumber);
if (lastPageNumber == 1) return;
/**
* Iterate over any new pages, loading their posts.
*/
for (
let idx = globalStore.loader.lastPage + 1;
idx <= lastPageNumber;
idx++
) {
const pageID = `#content-page-${idx}`;
print(2, `Parsing page "${pageID}" [${idx}/${lastPageNumber}]...`);
const newPosts = $(pageID).children();
await loadNewPosts(newPosts);
globalStore.loader.lastPage = idx;
}
}
/**
* This function is called repeatedly to check if new posts
* need to be loaded, and if so, updates the globalStore.
*/
async function ensureMinimumPosts() {
print(2, 'Ensuring minimum posts...');
/**
* If the `globalStore.loader.lastPage` is 0, then we have not
* yet initialized — so we load the first page while ignoring
* the first post (the promotional post).
*/
if (globalStore.loader.lastPage === 0) {
print(2, 'Loading first page...');
const newPosts = ref_elem_Content
.children()
.first()
.children()
.slice(1);
await loadNewPosts(newPosts);
globalStore.loader.lastPage = 1;
}
let prevPostCount = globalStore.loader.posts.length;
while (
prevPostCount - globalStore.ui.currentPostIndex <
LOOKAHEAD_POST_COUNT
) {
print(2, 'Scrolling to next "page" for more posts...');
unsafeWindow.scrollTo(0, unsafeWindow.document.body.scrollHeight);
// wait a split second to let the page load
await new Promise((r) => setTimeout(r, 450));
await parseNewPages();
if (prevPostCount === globalStore.loader.posts.length) {
// should have waited longer
print(
1,
'Haven\'t found new page yet, waiting before trying again...'
);
await new Promise((r) =>
setTimeout(r, CHECK_NEW_PAGE_INTERVAL)
);
} else {
prevPostCount = globalStore.loader.posts.length;
}
}
}
async function loadPostMediaByIndex(index) {
if (
Date.now() - globalStore.loader.lastMediaLoad <
FETCH_MEDIA_RATE_LIMIT
) {
print(2, 'Media load rate limit exceeded');
const timeRemaining =
FETCH_MEDIA_RATE_LIMIT -
(Date.now() - globalStore.loader.lastMediaLoad);
await new Promise((r) => setTimeout(r, timeRemaining));
}
const post = globalStore.loader.posts[index];
if (!post) {
print(1, 'No post found for index', index);
return;
}
if (post.fullMedia) {
print(2, 'Media already loaded for post', index);
return;
}
print(2, 'Loading media for post', index);
// use jquery to create iframe to post.link
// then use jquery to find media in iframe
// then use jquery to remove iframe
const iframe = $(`<iframe src="${post.link}"></iframe>`);
iframe.css('display', 'none');
$('body').append(iframe);
let iframeLoading = true;
iframe.on('load', () => {
iframeLoading = false;
});
print(2, 'Waiting for iframe to load...');
await new Promise((r) => setTimeout(r, 2000));
while (iframeLoading) {
print(2, 'Still waiting for iframe to load...');
await new Promise((r) => setTimeout(r, 2000));
}
const media = iframe.contents().find('#image')[0];
print(2, 'iframe loaded with media:', media);
post.fullMedia = iframe;
globalStore.loader.lastMediaLoad = Date.now();
if (globalStore.ui.mounted) __GALLERIA.syncGlobalStore();
}
/**
* Initialize React application
*/
const __GALLERIA = ReactDOM.render(
e(Galleria, {
globalStore,
ensureMinimumPosts,
loadPostMediaByIndex,
}),
ref_elem_GalleryRoot[0]
);
})();