// ==UserScript==
// @name Post Gallery
// @namespace http://tampermonkey.net/
// @version 1.1.
// @description Transforms post list into a gallery.
// @author remuru
// @match *://kemono.cr/*
// @match *://coomer.st/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect kemono.cr
// @connect coomer.st
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// --- 1. Configuration & Settings ---
const DEFAULT_CONFIG = {
LAYOUT: { GRID_GAP: "16px", GRID_COLUMN_COUNT: 4 },
};
const MAX_CONCURRENT_DOWNLOADS = 10; // Hardcoded download limit
let settings; // Will hold loaded or default settings
const currentDomain = window.location.hostname;
let lastProcessedUrl = null;
function loadSettings() {
const savedSettings = localStorage.getItem('gallerySettings');
const defaults = {
gridColumnCount: DEFAULT_CONFIG.LAYOUT.GRID_COLUMN_COUNT,
};
settings = savedSettings ? { ...defaults, ...JSON.parse(savedSettings) } : defaults;
}
function saveSettings() {
localStorage.setItem('gallerySettings', JSON.stringify(settings));
}
// --- 2. UI & Styling ---
function addGlobalStyles() {
// Static styles that don't change
const STYLES = `
body { position: relative; } /* Needed for fixed panels */
.post-card { width: 100% !important; margin: 0 !important; break-inside: avoid; background: rgba(30, 32, 34, 0.8); border-radius: 8px; overflow: hidden; height: auto !important; transition: transform 0.2s ease, box-shadow 0.2s ease; }
.post-card:hover { transform: translateY(-3px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
#gallery-loader { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 20px; border-radius: 8px; z-index: 10001; display: flex; align-items: center; font-family: sans-serif; }
.loading-spinner { width: 20px; height: 20px; border: 3px solid #fff; border-radius: 50%; border-top-color: transparent; animation: spin 1s linear infinite; margin-right: 10px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Slider Styles */
.post-card__slider-container { position: relative; width: 100%; height: 0; padding-bottom: 125%; background-color: #000; overflow: hidden; }
.slider-wrapper { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; transition: transform 0.4s ease-in-out; }
.slider-media-item { width: 100%; height: 100%; object-fit: cover; flex-shrink: 0; display: block; background-color: #000; }
.slider-btn { position: absolute; top: 50%; transform: translateY(-50%); z-index: 10; background-color: rgba(20, 20, 20, 0.6); color: white; border: none; border-radius: 50%; width: 32px; height: 32px; font-size: 20px; line-height: 32px; text-align: center; cursor: pointer; opacity: 0; transition: opacity 0.2s ease, background-color 0.2s ease; user-select: none; }
.post-card:hover .slider-btn { opacity: 1; }
.slider-btn:hover { background-color: rgba(0, 0, 0, 0.8); }
.slider-btn-prev { left: 8px; }
.slider-btn-next { right: 8px; }
.slider-counter { position: absolute; top: 8px; right: 8px; z-index: 10; background-color: rgba(20, 20, 20, 0.7); color: white; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: bold; user-select: none; }
/* Paginator Settings Styles */
.paginator { display: flex; flex-flow: column; align-items: center; justify-content: center; }
#gallery-paginator-settings { position: relative; margin-left: 20px; }
#gallery-settings-toggle { background: #3a3f44; color: #ddd; border: 1px solid #555; border-radius: 4px; padding: 5px 10px; cursor: pointer; font-size: 14px; }
#gallery-settings-toggle:hover { background: #4a4f54; }
#gallery-settings-dropdown { display: none; position: absolute; margin-top: 10px; left: 50%; transform: translateX(-50%); margin-bottom: 10px; background-color: rgba(30, 32, 34, 0.95); padding: 15px; border-radius: 8px; z-index: 1000; border: 1px solid #444; width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }
#gallery-settings-dropdown input[type=range] { width: 100%; }
`;
GM_addStyle(STYLES);
// Create a dedicated style element for dynamic rules
const dynamicStyles = document.createElement('style');
dynamicStyles.id = 'dynamic-gallery-styles';
document.head.appendChild(dynamicStyles);
}
function updateGridStyle() {
const dynamicStyles = document.getElementById('dynamic-gallery-styles');
if (dynamicStyles) {
dynamicStyles.innerHTML = `.card-list--legacy .card-list__items {
display: grid !important;
grid-template-columns: repeat(${settings.gridColumnCount}, 1fr);
gap: ${DEFAULT_CONFIG.LAYOUT.GRID_GAP};
padding-top: ${DEFAULT_CONFIG.LAYOUT.GRID_GAP};
width: 100%; margin: 0 auto;
}`;
}
}
function createPaginatorSettings() {
if (document.getElementById('gallery-paginator-settings')) return;
const paginatorMenu = document.querySelector('.paginator menu');
if (!paginatorMenu) return;
const container = document.createElement('div');
container.id = 'gallery-paginator-settings';
container.innerHTML = `
<button id="gallery-settings-toggle">
Колонки: <span id="gallery-column-count-display">${settings.gridColumnCount}</span>
</button>
<div id="gallery-settings-dropdown">
<input type="range" id="column-count-slider" min="1" max="12" step="1" value="${settings.gridColumnCount}">
</div>
`;
paginatorMenu.insertAdjacentElement('afterend', container);
const toggleBtn = container.querySelector('#gallery-settings-toggle');
const dropdown = container.querySelector('#gallery-settings-dropdown');
const slider = container.querySelector('#column-count-slider');
const display = container.querySelector('#gallery-column-count-display');
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.style.display = dropdown.style.display === 'block' ? 'none' : 'block';
});
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
dropdown.style.display = 'none';
}
});
slider.addEventListener('input', () => {
settings.gridColumnCount = parseInt(slider.value, 10);
display.textContent = settings.gridColumnCount;
updateGridStyle();
});
slider.addEventListener('change', saveSettings);
}
function showLoader() {
if (document.getElementById('gallery-loader')) return;
const loader = document.createElement('div');
loader.id = 'gallery-loader';
loader.innerHTML = `<div class="loading-spinner"></div><span>Gallery: Loading...</span>`;
document.body.appendChild(loader);
}
function hideLoader() {
const loader = document.getElementById('gallery-loader');
if (loader) loader.remove();
}
// --- 3. API & Data Handling ---
function determinePageContext() {
const pathname = window.location.pathname;
const searchParams = new URLSearchParams(window.location.search);
const query = searchParams.get('q');
const userProfileMatch = pathname.match(/^\/([^/]+)\/user\/([^/]+)$/);
if (userProfileMatch && !query) return { type: 'profile', service: userProfileMatch[1], userId: userProfileMatch[2] };
if (userProfileMatch && query) return { type: 'user_search', service: userProfileMatch[1], userId: userProfileMatch[2], query };
if (pathname === '/posts/popular') return { type: 'popular_posts', date: searchParams.get('date') || 'none', period: searchParams.get('period') || 'recent' };
if (pathname === '/posts') return { type: 'global_search', query: query || null };
return null;
}
function buildApiUrl(context, offset) {
let baseApiUrl = `https://${currentDomain}/api/v1`;
let queryParams = [];
if (!context) return null;
switch (context.type) {
case 'profile':
if (offset > 0) queryParams.push(`o=${offset}`);
return `${baseApiUrl}/${context.service}/user/${context.userId}/posts?${queryParams.join('&')}`;
case 'user_search':
queryParams.push(`q=${encodeURIComponent(context.query)}`);
if (offset > 0) queryParams.push(`o=${offset}`);
return `${baseApiUrl}/${context.service}/user/${context.userId}/posts?${queryParams.join('&')}`;
case 'global_search':
if (offset > 0) queryParams.push(`o=${offset}`);
if (context.query) queryParams.push(`q=${encodeURIComponent(context.query)}`);
return `${baseApiUrl}/posts?${queryParams.join('&')}`;
case 'popular_posts':
if (context.date !== 'none' && context.period !== "recent") queryParams.push(`date=${encodeURIComponent(context.date)}`);
if (offset > 0) queryParams.push(`o=${offset}`);
queryParams.push(`period=${encodeURIComponent(context.period)}`);
return `${baseApiUrl}/posts/popular?${queryParams.join('&')}`;
default:
return null;
}
}
function fetchData(apiUrl) {
const headers = { "Accept": "application/json", "Referer": window.location.href, "X-Requested-With": "XMLHttpRequest" };
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url: apiUrl, headers: headers,
onload: resp => {
if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(`Error parsing JSON: ${e.message}`); } }
else { reject(`API request failed: ${resp.status}`); }
},
onerror: resp => reject(`API request error: ${resp.statusText || 'Network error'}`)
});
});
}
function processApiData(posts) {
const mediaMap = new Map();
const videoExtensions = ['.mp4', '.webm', '.mov', '.m4v'];
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];
for (const post of posts) {
if (!post.id) continue;
const postVideos = [], postImages = [];
const allFiles = [post.file, ...post.attachments].filter(f => f && f.path);
for (const file of allFiles) {
if (!file.path) continue;
const fileName = file.name ? file.name.toLowerCase() : '';
const fileUrl = `https://${currentDomain}/data${file.path}`;
if (videoExtensions.some(ext => fileName.endsWith(ext))) {
postVideos.push({ type: 'video', src: fileUrl });
} else if (imageExtensions.some(ext => fileName.endsWith(ext))) {
const thumbnailUrl = fileUrl.replace(`https://${currentDomain}/`, `https://img.${currentDomain}/thumbnail/`);
postImages.push({ type: 'image', thumbnail: thumbnailUrl, full: fileUrl });
}
}
const uniqueVideos = Array.from(new Map(postVideos.map(v => [v.src, v])).values());
const uniqueImages = Array.from(new Map(postImages.map(img => [img.full, img])).values());
const posterUrl = uniqueImages.length > 0 ? uniqueImages[0].thumbnail : null;
uniqueVideos.forEach(video => video.poster = posterUrl);
const allMedia = [...uniqueVideos, ...uniqueImages];
mediaMap.set(post.id, { media: allMedia });
}
return mediaMap;
}
// --- 4. DOM Manipulation & Interactivity ---
function updateDOM(mediaMap) {
document.querySelectorAll('.post-card').forEach(card => {
const postId = card.dataset.id;
if (!postId) return;
const data = mediaMap.get(postId);
if (!data || !data.media || data.media.length === 0) return;
const mediaItems = data.media;
const originalContainer = card.querySelector('.post-card__image-container, .post-card__video-container');
const newMediaContainer = document.createElement('div');
newMediaContainer.className = 'post-card__slider-container';
newMediaContainer.dataset.currentIndex = "0";
const wrapper = document.createElement('div');
wrapper.className = 'slider-wrapper';
mediaItems.forEach(item => {
let mediaElement;
if (item.type === 'video') {
mediaElement = document.createElement('video');
mediaElement.className = 'slider-media-item';
Object.assign(mediaElement, { loop: true, muted: true, preload: "metadata", controls: true });
if (item.poster) mediaElement.poster = item.poster;
mediaElement.dataset.src = item.src;
lazyLoadObserver.observe(mediaElement);
} else {
mediaElement = document.createElement('img');
mediaElement.className = 'slider-media-item';
mediaElement.src = item.thumbnail;
mediaElement.dataset.fullSrc = item.full;
}
wrapper.appendChild(mediaElement);
});
newMediaContainer.appendChild(wrapper);
if (mediaItems.length > 1) {
newMediaContainer.insertAdjacentHTML('beforeend', `
<button class="slider-btn slider-btn-prev">❮</button>
<button class="slider-btn slider-btn-next">❯</button>
<div class="slider-counter">1 / ${mediaItems.length}</div>
`);
}
if (originalContainer) {
originalContainer.replaceWith(newMediaContainer);
} else {
const header = card.querySelector('.post-card__header');
if (header) header.insertAdjacentElement('afterend', newMediaContainer);
else card.prepend(newMediaContainer);
}
});
// Activate all sliders on the page
document.querySelectorAll('.post-card__slider-container').forEach(slider => {
const wrapper = slider.querySelector('.slider-wrapper');
const prevBtn = slider.querySelector('.slider-btn-prev');
const nextBtn = slider.querySelector('.slider-btn-next');
const counter = slider.querySelector('.slider-counter');
const mediaElements = slider.querySelectorAll('.slider-media-item');
const totalSlides = mediaElements.length;
if (totalSlides <= 1) return;
function updateSliderView(previousIndex) {
const currentIndex = parseInt(slider.dataset.currentIndex, 10);
wrapper.style.transform = `translateX(-${currentIndex * 100}%)`;
if (counter) counter.textContent = `${currentIndex + 1} / ${totalSlides}`;
if (prevBtn) prevBtn.style.display = currentIndex === 0 ? 'none' : 'block';
if (nextBtn) nextBtn.style.display = currentIndex === totalSlides - 1 ? 'none' : 'block';
const currentElement = mediaElements[currentIndex];
if (currentElement.tagName === 'VIDEO') {
currentElement.play().catch(e => { /* Play was likely interrupted by user action */ });
}
if (previousIndex !== undefined && previousIndex !== currentIndex) {
const previousElement = mediaElements[previousIndex];
if (previousElement.tagName === 'VIDEO') {
previousElement.pause();
}
}
}
prevBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const currentIndex = parseInt(slider.dataset.currentIndex, 10);
if (currentIndex > 0) {
slider.dataset.currentIndex = currentIndex - 1;
updateSliderView(currentIndex);
}
});
nextBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const currentIndex = parseInt(slider.dataset.currentIndex, 10);
if (currentIndex < totalSlides - 1) {
slider.dataset.currentIndex = currentIndex + 1;
updateSliderView(currentIndex);
}
});
updateSliderView();
});
}
// --- 5. Performance & Loading ---
let downloadQueue = [];
let activeDownloads = 0;
function getVideoMimeType(url) {
const extension = url.split('.').pop().toLowerCase();
switch (extension) { case 'mp4': case 'm4v': return 'video/mp4'; case 'webm': return 'video/webm'; case 'mov': return 'video/quicktime'; default: return 'video/mp4'; }
}
function startVideoLoad(video) {
const onComplete = () => {
activeDownloads--;
processQueue();
};
video.addEventListener('loadeddata', onComplete, { once: true });
video.addEventListener('error', () => {
console.error('Video load error:', video.dataset.src);
onComplete();
}, { once: true });
const source = document.createElement('source');
source.src = video.dataset.src;
source.type = getVideoMimeType(video.dataset.src);
video.appendChild(source);
video.load();
}
function processQueue() {
while (activeDownloads < MAX_CONCURRENT_DOWNLOADS && downloadQueue.length > 0) {
activeDownloads++;
const videoToLoad = downloadQueue.shift();
startVideoLoad(videoToLoad);
}
}
const lazyLoadObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const mediaElement = entry.target;
observer.unobserve(mediaElement);
if (mediaElement.tagName === 'VIDEO') {
downloadQueue.push(mediaElement);
processQueue();
}
}
});
}, { rootMargin: "200px" });
// --- 6. Main Execution Logic ---
async function runScript() {
if (!document.querySelector('.card-list__items') || lastProcessedUrl === window.location.href) return;
lastProcessedUrl = window.location.href;
showLoader();
createPaginatorSettings(); // Ensure settings UI is present
try {
const context = determinePageContext();
const offset = new URLSearchParams(window.location.search).get('o') || 0;
const apiUrl = buildApiUrl(context, offset);
if (!apiUrl) throw new Error("Could not build API URL.");
const apiResponse = await fetchData(apiUrl);
const postsArray = Array.isArray(apiResponse) ? apiResponse : (apiResponse && Array.isArray(apiResponse.posts)) ? apiResponse.posts : [];
if (postsArray.length === 0) {
hideLoader();
return;
}
const mediaMap = processApiData(postsArray);
updateDOM(mediaMap);
} catch (error) {
console.error("Gallery script execution failed:", error);
} finally {
hideLoader();
}
}
function setupNavigationObserver() {
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
// Use a small timeout to allow the DOM to update after pushState
setTimeout(runScript, 100);
};
window.addEventListener('popstate', runScript);
const observer = new MutationObserver((mutations) => {
// A more robust check for page changes
for(const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
if (lastProcessedUrl !== window.location.href && document.querySelector('.card-list__items')) {
runScript();
break;
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// --- Script Initialization ---
function initialize() {
loadSettings();
addGlobalStyles();
updateGridStyle();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runScript);
} else {
runScript();
}
setupNavigationObserver();
}
initialize();
})();