NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Wallhaven Slideshow
// @namespace https://github.com/Pardim93/wallhaven-slideshow
// @version 0.0.1
// @description Fullscreen image slideshow for Wallhaven with scale-to-fill default zoom
// @author Pardim93
// @match *://wallhaven.cc/*
// @match *://www.wallhaven.cc/*
// @grant none
// @connect wallhaven.cc
// @run-at document-idle
// @noframes
// @license MIT
// @downloadURL https://github.com/Pardim93/wallhaven-slideshow/raw/main/wallhaven-slideshow.user.js
// @updateURL https://github.com/Pardim93/wallhaven-slideshow/raw/main/wallhaven-slideshow.user.js
// ==/UserScript==
/*
* Wallhaven Slideshow
* ────────────────────────────────────────────────────────────────────────
* A userscript that adds a fullscreen image slideshow to wallhaven.cc.
*
* HOW IT WORKS:
* 1. On page load, the script scans for thumbnail elements (figure.thumb)
* and collects their wallpaper IDs and full-image URLs into a Map.
* 2. A floating "Slideshow (N)" button appears in the bottom-right corner
* once thumbnails are found. Clicking it opens a fullscreen overlay.
* 3. The overlay uses a closed Shadow DOM for complete style isolation from
* Wallhaven's own CSS. All UI (topbar, slides, thumbnails, controls)
* lives inside the shadow root.
* 4. Images are lazy-loaded: only the active slide and its immediate
* neighbors (±2) are kept in the DOM (DOM virtualization). This keeps
* memory usage low even with hundreds of wallpapers.
* 5. When the user navigates near the end of collected wallpapers, the
* script automatically fetches more pages from Wallhaven's API,
* providing an "endless" slideshow experience.
*
* KEY FEATURES:
* - Zoom & pan: scroll wheel to zoom, double-click to toggle zoom,
* drag to pan when zoomed in. Zoom centers on cursor position.
* - Autoplay: advances slides every 6.5 seconds; timer resets on
* manual navigation to prevent rapid double-advances.
* - Immersive mode: hides all UI except the wallpaper (press C).
* - Touch support: swipe left/right to navigate, swipe down to close.
* - Settings panel: gear icon to configure a Wallhaven API key for
* higher rate limits.
* - SPA-aware: detects Wallhaven's client-side navigation and resets
* the collection when the page context changes.
*
* KEYBOARD SHORTCUTS:
* ← / → Previous / Next slide
* ↑ / ↓ Zoom in / out
* + / - Zoom in / out (alternative)
* Space Play / Pause autoplay
* F Toggle fullscreen
* C Toggle immersive mode (hide UI)
* R / 0 Reset zoom to default
* Esc Close slideshow
*
* WALLHAVEN URL STRUCTURE:
* Full image: https://w.wallhaven.cc/full/{prefix}/wallhaven-{id}.{ext}
* Thumbnail: https://th.wallhaven.cc/small/{prefix}/{id}.jpg
* Where {prefix} = first 2 characters of the wallpaper ID.
* ────────────────────────────────────────────────────────────────────────
*/
(function () {
'use strict';
// ── State ────────────────────────────────────────────────────────────
// Core collection: maps wallpaper ID → { id, full, fullAlt, thumb }
// Populated by scanning the DOM (thumbnails) and the Wallhaven API.
const collected = new Map();
// Cached array of collected.values(), invalidated when cacheVersion changes.
// Avoids creating a new array on every getImages() call (which happens frequently).
let cachedImages = null;
let cacheVersion = 0;
// Launch button (floating "Slideshow (N)" in bottom-right corner)
let btn, btnLabel;
// Slideshow overlay elements:
// host = the outer div appended to document.body
// shadow = closed Shadow DOM root (style isolation from wallhaven.cc CSS)
// ui = object holding references to all shadow DOM elements
let host, shadow, ui = {};
// Slideshow playback state
let current = 0; // index of the currently active slide
let playing = false; // whether autoplay is active
let immersive = false; // whether immersive mode (hide all UI) is active
// Zoom & pan state:
// baseZoom = "cover" scale that fills the viewport (computed from image/viewport dimensions)
// zoom = user-controlled multiplier relative to baseZoom (1 = fill, >1 = zoomed in)
// totalZoom applied = baseZoom * zoom
// panX/panY = pixel offset from center, clamped to keep image in viewport
let zoom = 1;
let baseZoom = 1;
let panX = 0;
let panY = 0;
// Mouse drag state (for panning when zoomed in)
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragStartPanX = 0;
let dragStartPanY = 0;
// Touch swipe state (for mobile navigation)
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
// Slide DOM virtualization: only active ± SLIDE_WINDOW slides are in the DOM
let slideCount = 0; // number of thumbnails synced to the UI
let isFetching = false; // true while an API request is in flight
const SLIDE_WINDOW = 2; // how many slides to keep on each side of the active slide
// API pagination state for fetching additional wallpapers from wallhaven.cc/api/v1/search
let reachedBottom = false; // true when all API pages have been loaded
let apiPage = 1; // current page number
let apiLastPage = null; // total pages reported by the API (null until first response)
let apiSeed = null; // random seed for consistent results on /random sorting
let apiBaseUrl = null; // constructed API URL based on the current wallhaven page
// Timers
let playTimer = null; // autoplay setTimeout handle
let hideTimer = null; // UI auto-hide setTimeout handle
let scanTimer = null; // debounced scan setTimeout handle
let scanInterval = null; // periodic scan setInterval handle
// SPA navigation & scan optimization
let lastUrl = location.href; // tracks URL changes for SPA detection
let lastScanSize = 0;
let stableScanCount = 0; // how many consecutive scans found no new images (stops interval after 2)
// ── Wallpaper ID validation ──────────────────────────────────────────
// Wallhaven IDs are 4-8 alphanumeric characters (e.g. "x6d", "94xlk8").
// Returns the ID if valid, null otherwise.
function parseWallpaperId(id) {
if (!id || !/^[a-z0-9]{4,8}$/i.test(id)) return null;
return id;
}
// ── Collect from a DOM thumbnail element ─────────────────────────────
// Extracts wallpaper data from a <figure class="thumb" data-wallpaper-id="...">
// element on the wallhaven page. Builds the full-resolution and thumbnail URLs
// from the ID using Wallhaven's URL structure:
// Full: https://w.wallhaven.cc/full/{prefix}/wallhaven-{id}.{ext}
// Thumb: https://th.wallhaven.cc/small/{prefix}/{id}.jpg
// The extension (jpg/png) is determined by checking for a .png badge in the
// thumbnail's info area. An alternate URL with the other extension is stored
// as a fallback in case the primary 404s.
function collectFromFigure(fig) {
const id = fig.getAttribute('data-wallpaper-id');
if (!id || collected.has(id)) return;
const parsed = parseWallpaperId(id);
if (!parsed) return;
const prefix = id.substring(0, 2);
const isPng = fig.querySelector('.thumb-info .png') !== null;
const ext = isPng ? 'png' : 'jpg';
collected.set(id, {
id,
full: 'https://w.wallhaven.cc/full/' + prefix + '/wallhaven-' + id + '.' + ext,
fullAlt: 'https://w.wallhaven.cc/full/' + prefix + '/wallhaven-' + id + '.' + (isPng ? 'jpg' : 'png'),
thumb: 'https://th.wallhaven.cc/small/' + prefix + '/' + id + '.jpg',
});
cacheVersion++;
}
// ── Collect from an API response item ────────────────────────────────
// Processes a single wallpaper object from the Wallhaven API JSON response.
// The API returns: { id, path (full URL), file_type, thumbs: { small, large, original } }
// If the path doesn't include "/full/" (some API versions omit it), we inject it.
// Falls back to constructing the URL from the ID if path is empty.
function collectFromApiItem(item) {
const id = item.id;
if (!id || collected.has(id)) return;
let path = item.path || '';
// Only modify path if it points to w.wallhaven.cc but doesn't include /full/
if (path && !path.includes('/full/') && /^https?:\/\/w\.wallhaven\.cc\/[^f]/.test(path)) {
path = path.replace(/^(https?:\/\/w\.wallhaven\.cc\/)/, '$1full/');
}
const thumbs = item.thumbs || {};
const ext = (item.file_type || 'image/jpeg').includes('png') ? 'png' : 'jpg';
const prefix = id.substring(0, 2);
collected.set(id, {
id,
full: path || ('https://w.wallhaven.cc/full/' + prefix + '/wallhaven-' + id + '.' + ext),
fullAlt: 'https://w.wallhaven.cc/full/' + prefix + '/wallhaven-' + id + '.' + (ext === 'png' ? 'jpg' : 'png'),
thumb: thumbs.small || ('https://th.wallhaven.cc/small/' + prefix + '/' + id + '.jpg'),
});
cacheVersion++;
}
// ── Build API base URL from current page ─────────────────────────────
// Translates the current wallhaven.cc page URL into a Wallhaven API search
// endpoint. This allows the slideshow to fetch additional wallpapers that
// match the user's current browsing context (search filters, categories, etc).
//
// Supported pages:
// /search → use existing query params as-is
// /latest → sorting=date_added, order=desc
// /hot → sorting=hot, order=desc
// /toplist → sorting=toplist, order=desc
// /random → sorting=random
// /u/{user} → q=@{username} (user's uploads)
// /u/{user}/uploads → q=@{username}
//
// Returns null for pages without a clear search context (e.g. homepage),
// which disables API pagination for that session.
function buildApiBaseUrl() {
const url = new URL(location.href);
const path = url.pathname;
const params = new URLSearchParams(url.search);
if (path.startsWith('/search')) {
// /search page: preserve the user's existing query parameters
} else if (path === '/latest') {
params.set('sorting', 'date_added');
params.set('order', 'desc');
} else if (path === '/hot') {
params.set('sorting', 'hot');
params.set('order', 'desc');
} else if (path === '/toplist') {
params.set('sorting', 'toplist');
params.set('order', 'desc');
} else if (path === '/random') {
params.set('sorting', 'random');
} else if (path.match(/^\/u\/([^/]+)\/uploads/)) {
// User uploads page: search by @username
const username = path.match(/^\/u\/([^/]+)/)[1];
params.set('q', '@' + username);
} else if (path.match(/^\/u\/([^/]+)$/)) {
// User profile page: search by @username
const username = path.match(/^\/u\/([^/]+)/)[1];
params.set('q', '@' + username);
} else {
return null;
}
// Remove page param — we manage pagination ourselves
params.delete('page');
return 'https://wallhaven.cc/api/v1/search?' + params.toString();
}
// ── Scan DOM for thumbnail elements ──────────────────────────────────
// Finds all <figure class="thumb" data-wallpaper-id="..."> elements on the
// page and collects them into the `collected` Map. Also tracks whether new
// images were found — after 2 consecutive scans with no new images, the
// periodic scan interval pauses to save resources.
function scanImages() {
const prevSize = collected.size;
document.querySelectorAll('figure.thumb[data-wallpaper-id]').forEach(collectFromFigure);
updateButton();
if (collected.size === prevSize) {
stableScanCount++;
} else {
stableScanCount = 0;
}
// If the slideshow is open, sync any newly collected wallpapers into the UI
if (host && host.style.display !== 'none') {
syncNewSlides();
}
}
// Starts a 5-second polling interval to scan for new thumbnails.
// Automatically stops after 2 consecutive scans find no new images.
// Restarted on SPA navigation and when the slideshow opens.
function startScanInterval() {
clearInterval(scanInterval);
stableScanCount = 0;
scanInterval = setInterval(function () {
scanImages();
if (stableScanCount >= 2) {
clearInterval(scanInterval);
}
}, 5000);
}
// Debounced version of scanImages, used by the MutationObserver to avoid
// rapid re-scans when multiple DOM mutations fire in quick succession.
function debouncedScan() {
clearTimeout(scanTimer);
scanTimer = setTimeout(scanImages, 300);
}
// ── Launch button ────────────────────────────────────────────────────
// Creates the floating "Slideshow (N)" button in the bottom-right corner.
// It appears once at least one wallpaper thumbnail is found on the page.
// Clicking it opens the fullscreen slideshow overlay.
function createButton() {
btn = document.createElement('div');
btn.id = 'whs-launch-btn';
Object.assign(btn.style, {
position: 'fixed',
bottom: '24px',
right: '24px',
zIndex: '2147483646',
display: 'none',
alignItems: 'center',
gap: '8px',
padding: '10px 18px',
borderRadius: '24px',
background: '#4f87c7',
color: '#fff',
fontSize: '14px',
fontWeight: '600',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
cursor: 'pointer',
boxShadow: '0 2px 12px rgba(79,135,199,0.4)',
transition: 'transform 0.15s, box-shadow 0.15s',
userSelect: 'none',
});
const icon = document.createElement('span');
icon.textContent = '▶';
icon.style.fontSize = '12px';
btnLabel = document.createElement('span');
btnLabel.textContent = 'Slideshow (0)';
btn.appendChild(icon);
btn.appendChild(btnLabel);
btn.addEventListener('mouseenter', function () {
btn.style.transform = 'scale(1.05)';
btn.style.boxShadow = '0 4px 20px rgba(79,135,199,0.6)';
});
btn.addEventListener('mouseleave', function () {
btn.style.transform = 'scale(1)';
btn.style.boxShadow = '0 2px 12px rgba(79,135,199,0.4)';
});
btn.addEventListener('click', openSlideshow);
document.body.appendChild(btn);
}
// Shows/hides the launch button and updates the count display.
// Called after each scan to reflect the current number of collected wallpapers.
function updateButton() {
if (!btn) return;
const count = collected.size;
if (count > 0) {
btn.style.display = 'flex';
btnLabel.textContent = 'Slideshow (' + count + ')';
} else {
btn.style.display = 'none';
}
}
// ── Slideshow overlay ────────────────────────────────────────────────
// Creates the fullscreen slideshow overlay. Uses a closed Shadow DOM to
// completely isolate the slideshow's CSS from wallhaven.cc's styles.
//
// The shadow root contains:
// #root — main container with fade-in/out transition
// #slides — container for slide elements (managed by virtualization)
// #topbar — top bar with counter, title, and tool buttons
// #prev / #next — circular navigation arrows
// #thumbs — horizontal scrollable thumbnail strip at the bottom
// #spinner — loading spinner shown while images load
// #exit-hint — shown in immersive mode to explain how to exit
// #zoom-badge — displays current zoom percentage
// #load-more — indicator shown while fetching more wallpapers from API
// #settings-panel — modal for entering a Wallhaven API key
//
// All event listeners for the slideshow are attached here (mouse, touch,
// keyboard, wheel, resize). The keyboard listener is added/removed in
// openSlideshow/closeSlideshow to avoid capturing keys when the slideshow
// is closed.
function createSlideshow() {
host = document.createElement('div');
host.id = 'whs-host';
Object.assign(host.style, {
position: 'fixed',
inset: '0',
zIndex: '2147483647',
display: 'none',
});
shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
#root {
position: fixed;
inset: 0;
background: #ed0767;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #fff;
opacity: 0;
transition: opacity 0.3s;
}
#root.visible { opacity: 1; }
.slide {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
opacity: 0;
transition: opacity 0.35s ease;
pointer-events: none;
}
.slide.active {
opacity: 1;
pointer-events: auto;
}
.slide img {
max-width: none;
max-height: none;
width: auto;
height: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 8px 40px rgba(0,0,0,0.8);
transform-origin: center center;
transition: transform 0.15s ease-out;
will-change: transform;
user-select: none;
-webkit-user-drag: none;
}
.slide img.zoomed { cursor: grab; }
.slide img.dragging { transition: none; cursor: grabbing; }
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 52px;
height: 52px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.12);
color: #fff;
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
transition: background 0.2s, opacity 0.3s;
z-index: 10;
}
.nav:hover { background: rgba(255,255,255,0.28); }
#prev { left: 16px; }
#next { right: 16px; }
#topbar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: linear-gradient(to bottom, rgba(0,0,0,0.55), transparent);
z-index: 20;
transition: opacity 0.3s;
}
#topbar.hidden {
opacity: 0;
pointer-events: none;
}
#counter {
color: #fff;
font-size: 14px;
font-weight: 600;
background: rgba(255,255,255,0.12);
padding: 5px 14px;
border-radius: 16px;
backdrop-filter: blur(6px);
}
#bar-title {
color: #fff;
font-size: 13px;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40vw;
}
#bar-tools {
display: flex;
gap: 8px;
}
.tool {
width: 38px;
height: 38px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.12);
color: #fff;
font-size: 17px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
transition: background 0.2s;
}
.tool:hover { background: rgba(255,255,255,0.28); }
.tool.close {
background: rgba(79,135,199,0.7);
}
.tool.close:hover {
background: rgba(79,135,199,1);
}
#thumbs {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 6px;
padding: 10px 16px;
overflow-x: auto;
background: linear-gradient(to top, rgba(0,0,0,0.6), transparent);
z-index: 15;
transition: opacity 0.3s;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.2) transparent;
}
#thumbs.hidden {
opacity: 0;
pointer-events: none;
}
#thumbs::-webkit-scrollbar { height: 4px; }
#thumbs::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.2);
border-radius: 4px;
}
.thumb {
width: 54px;
height: 54px;
border-radius: 5px;
object-fit: cover;
cursor: pointer;
opacity: 0.4;
border: 2px solid transparent;
flex-shrink: 0;
transition: opacity 0.15s, border-color 0.15s, transform 0.15s;
}
.thumb:hover {
opacity: 0.75;
transform: scale(1.08);
}
.thumb.active {
opacity: 1;
border-color: #4f87c7;
}
#spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
#spinner.hidden { display: none; }
.spin {
width: 36px;
height: 36px;
border: 3px solid rgba(255,255,255,0.15);
border-top-color: #4f87c7;
border-radius: 50%;
animation: sp 0.7s linear infinite;
}
@keyframes sp {
to { transform: rotate(360deg); }
}
#root.immersive #topbar { display: none !important; }
#root.immersive #thumbs { display: none !important; }
#root.immersive .slide img {
border-radius: 0;
box-shadow: none;
}
#exit-hint {
position: absolute;
bottom: 16px;
right: 16px;
display: none;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 18px;
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.65);
font-size: 12px;
cursor: pointer;
z-index: 25;
backdrop-filter: blur(6px);
transition: opacity 0.3s, background 0.2s;
user-select: none;
}
#exit-hint:hover { background: rgba(255,255,255,0.22); }
#root.immersive #exit-hint { display: flex; opacity: 1; pointer-events: auto; }
#exit-hint.hidden {
opacity: 0;
pointer-events: none;
}
#zoom-badge {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: none;
align-items: center;
padding: 5px 14px;
border-radius: 16px;
background: rgba(255,255,255,0.12);
color: #fff;
font-size: 13px;
font-weight: 600;
backdrop-filter: blur(6px);
z-index: 18;
transition: opacity 0.3s;
user-select: none;
}
#zoom-badge.visible { display: flex; }
#zoom-badge.hidden {
opacity: 0;
pointer-events: none;
}
#load-more {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: none;
align-items: center;
gap: 8px;
padding: 8px 18px;
border-radius: 20px;
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8);
font-size: 13px;
z-index: 19;
backdrop-filter: blur(6px);
transition: opacity 0.3s;
user-select: none;
}
#load-more.visible { display: flex; }
#load-more.hidden {
opacity: 0;
pointer-events: none;
}
#load-more .mini-spin {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: #4f87c7;
border-radius: 50%;
animation: sp 0.7s linear infinite;
}
#settings-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(20,20,30,0.95);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 12px;
padding: 20px 24px;
min-width: 320px;
z-index: 50;
backdrop-filter: blur(12px);
box-shadow: 0 12px 48px rgba(0,0,0,0.6);
}
#settings-panel.hidden {
display: none;
}
.sp-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
}
.sp-close {
background: none;
border: none;
color: rgba(255,255,255,0.6);
font-size: 16px;
cursor: pointer;
padding: 4px;
}
.sp-close:hover { color: #fff; }
.sp-label {
display: block;
font-size: 13px;
color: rgba(255,255,255,0.7);
margin-bottom: 6px;
}
.sp-row {
display: flex;
gap: 8px;
}
.sp-input {
flex: 1;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
padding: 8px 12px;
color: #fff;
font-size: 13px;
outline: none;
}
.sp-input:focus {
border-color: #4f87c7;
}
.sp-save {
background: #4f87c7;
border: none;
border-radius: 6px;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.sp-save:hover { background: #5a9ad8; }
.sp-hint {
margin-top: 8px;
font-size: 11px;
color: rgba(255,255,255,0.4);
}
</style>
<div id="root">
<div id="spinner"><div class="spin"></div></div>
<div id="slides"></div>
<div id="topbar">
<div id="counter">1 / 1</div>
<div id="bar-title">Wallhaven Slideshow</div>
<div id="bar-tools">
<button class="tool" id="settings" title="Settings">⚙</button>
<button class="tool" id="immersive" title="Clean mode — hide UI (C)">⬜</button>
<button class="tool" id="play" title="Play/Pause (Space)">▶</button>
<button class="tool" id="fs" title="Fullscreen (F)">⛶</button>
<button class="tool close" id="close" title="Close (Esc)">✕</button>
</div>
</div>
<button class="nav" id="prev" title="Previous (Left arrow)">❮</button>
<button class="nav" id="next" title="Next (Right arrow)">❯</button>
<div id="thumbs"></div>
<div id="exit-hint">⬜ Exit clean (C)</div>
<div id="zoom-badge">100%</div>
<div id="load-more"><div class="mini-spin"></div><span>Fetching more wallpapers…</span></div>
<div id="settings-panel" class="hidden">
<div class="sp-header">
<span>Settings</span>
<button class="sp-close" id="sp-close">✕</button>
</div>
<label class="sp-label">Wallhaven API Key</label>
<div class="sp-row">
<input type="text" id="sp-apikey" class="sp-input" placeholder="Enter your API key">
<button class="sp-save" id="sp-save">Save</button>
</div>
<div class="sp-hint">Get your key at wallhaven.cc/settings/account</div>
</div>
</div>
`;
const s = shadow.getElementById.bind(shadow);
ui = {
root: s('root'),
slides: s('slides'),
spinner: s('spinner'),
counter: s('counter'),
title: s('bar-title'),
topbar: s('topbar'),
prev: s('prev'),
next: s('next'),
play: s('play'),
fs: s('fs'),
close: s('close'),
thumbs: s('thumbs'),
immersive: s('immersive'),
exitHint: s('exit-hint'),
zoomBadge: s('zoom-badge'),
loadMore: s('load-more'),
settings: s('settings'),
settingsPanel: s('settings-panel'),
spClose: s('sp-close'),
spApikey: s('sp-apikey'),
spSave: s('sp-save'),
};
ui.prev.addEventListener('click', function (e) { e.stopPropagation(); go(-1); flashUI(); });
ui.next.addEventListener('click', function (e) { e.stopPropagation(); go(1); flashUI(); });
ui.play.addEventListener('click', togglePlay);
ui.fs.addEventListener('click', toggleFullscreen);
ui.immersive.addEventListener('click', toggleImmersive);
ui.exitHint.addEventListener('click', function (e) { e.stopPropagation(); toggleImmersive(); });
ui.close.addEventListener('click', closeSlideshow);
ui.settings.addEventListener('click', function (e) {
e.stopPropagation();
ui.settingsPanel.classList.toggle('hidden');
ui.spApikey.value = localStorage.getItem('whs-apikey') || '';
});
ui.spClose.addEventListener('click', function (e) {
e.stopPropagation();
ui.settingsPanel.classList.add('hidden');
});
ui.spSave.addEventListener('click', function (e) {
e.stopPropagation();
var key = ui.spApikey.value.trim();
if (key) localStorage.setItem('whs-apikey', key);
else localStorage.removeItem('whs-apikey');
ui.settingsPanel.classList.add('hidden');
});
ui.root.addEventListener('click', flashUI);
ui.root.addEventListener('wheel', onWheel, { passive: false });
ui.root.addEventListener('dblclick', onDblClick);
ui.root.addEventListener('mousedown', onDragStart);
ui.root.addEventListener('mousemove', function (e) { flashUI(); onDragMove(e); });
ui.root.addEventListener('mouseup', onDragEnd);
ui.root.addEventListener('mouseleave', onDragEnd);
ui.root.addEventListener('touchstart', onTouchStart, { passive: true });
ui.root.addEventListener('touchend', onTouchEnd, { passive: false });
window.addEventListener('resize', function () {
if (!host || host.style.display === 'none') return;
updateBaseZoom();
clampPan();
applyZoom();
});
document.body.appendChild(host);
}
// ── Cached image array ───────────────────────────────────────────────
// Returns an array of all collected wallpaper objects. Uses a versioned cache
// to avoid creating a new array on every call — getImages() is called very
// frequently (navigation, sync, load-more checks). The cache is invalidated
// when cacheVersion changes (new wallpapers collected or collection cleared).
function getImages() {
if (cachedImages && cachedImages._version === cacheVersion) return cachedImages;
cachedImages = Array.from(collected.values());
cachedImages._version = cacheVersion;
return cachedImages;
}
// ── Create a slide element ────────────────────────────────────────────
// Creates a <div class="slide"><img> element for a wallpaper.
// The image uses lazy loading: the actual URL is stored in data-src and
// only set as img.src when the slide becomes active (in showSlide).
//
// Error fallback chain: full URL → fullAlt (other extension) → thumbnail.
// This handles cases where the wallpaper was converted between jpg/png
// or the full image is unavailable.
function makeSlide(img, i) {
const slide = document.createElement('div');
slide.className = 'slide';
const imageEl = document.createElement('img');
imageEl.dataset.src = img.full;
imageEl.dataset.fallback = img.fullAlt || img.thumb;
imageEl.dataset.thumb = img.thumb;
imageEl.alt = 'Wallpaper ' + (i + 1);
imageEl.addEventListener('error', function () {
if (this.src && this.src !== this.dataset.fallback) {
this.src = this.dataset.fallback;
} else if (this.src !== this.dataset.thumb) {
this.src = this.dataset.thumb;
}
});
slide.appendChild(imageEl);
return slide;
}
// ── Create a thumbnail element ────────────────────────────────────────
// Creates an <img> for the bottom thumbnail strip. Uses native lazy loading
// since thumbnails are small and numerous. Clicking a thumbnail jumps to
// that slide via goTo(i).
function makeThumb(img, i) {
const thumb = document.createElement('img');
thumb.src = img.thumb;
thumb.alt = 'Thumb ' + (i + 1);
thumb.className = 'thumb';
thumb.loading = 'lazy';
thumb.addEventListener('click', function (e) { e.stopPropagation(); goTo(i); flashUI(); });
return thumb;
}
// ── Sync newly collected wallpapers into the UI ──────────────────────
// Called when new wallpapers are collected while the slideshow is open
// (either from DOM scanning or API fetching). Only adds thumbnails to the
// strip — slide elements are created on-demand by showSlide() (virtualization).
// Also updates the counter display and hides the "loading more" indicator.
function syncNewSlides() {
const images = getImages();
if (images.length <= slideCount) return;
for (let i = slideCount; i < images.length; i++) {
ui.thumbs.appendChild(makeThumb(images[i], i));
}
slideCount = images.length;
const thumbs = ui.thumbs.querySelectorAll('.thumb');
thumbs.forEach(function (t, i) { t.classList.toggle('active', i === current); });
ui.counter.textContent = (current + 1) + ' / ' + images.length;
if (ui.loadMore) {
ui.loadMore.classList.remove('visible');
ui.loadMore.classList.add('hidden');
}
}
// ── Fetch next page from Wallhaven API ────────────────────────────────
// Loads the next page of wallpapers from wallhaven.cc/api/v1/search.
// The API URL is built from the current page context (search filters,
// sorting, categories) by buildApiBaseUrl().
//
// Pagination state:
// apiPage — current page number (incremented on each fetch)
// apiLastPage — total pages reported by the API (null until first response)
// apiSeed — random seed for /random sorting consistency
//
// On success: collects new wallpapers, updates pagination state, syncs UI.
// On failure: shows "click to retry" message instead of silently failing.
//
// If the user is within 3 slides of the end and more pages exist,
// automatically chains another fetch after a 400ms delay.
function fetchNextPage() {
if (isFetching || reachedBottom) return;
if (!apiBaseUrl) {
reachedBottom = true;
return;
}
isFetching = true;
if (ui.loadMore) {
ui.loadMore.classList.add('visible');
ui.loadMore.classList.remove('hidden');
}
const next = apiPage + 1;
if (apiLastPage !== null && next > apiLastPage) {
reachedBottom = true;
isFetching = false;
if (ui.loadMore) {
ui.loadMore.classList.remove('visible');
ui.loadMore.classList.add('hidden');
}
return;
}
let apiUrl = apiBaseUrl + '&page=' + next;
if (apiSeed) apiUrl += '&seed=' + apiSeed;
const apiKey = localStorage.getItem('whs-apikey');
if (apiKey) apiUrl += '&apikey=' + encodeURIComponent(apiKey);
fetch(apiUrl, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (json) {
let added = 0;
if (json.data) {
json.data.forEach(function (item) {
if (!collected.has(item.id)) {
collectFromApiItem(item);
added++;
}
});
}
if (json.meta) {
apiPage = json.meta.current_page || next;
apiLastPage = json.meta.last_page || null;
if (json.meta.seed) apiSeed = json.meta.seed;
if (apiLastPage !== null && apiPage >= apiLastPage) {
reachedBottom = true;
}
}
isFetching = false;
if (ui.loadMore) {
ui.loadMore.classList.remove('visible');
if (added === 0) ui.loadMore.classList.add('hidden');
}
syncNewSlides();
if (added > 0 && current >= getImages().length - 3 && !reachedBottom) {
setTimeout(fetchNextPage, 400);
}
})
.catch(function () {
isFetching = false;
if (ui.loadMore) {
ui.loadMore.classList.add('visible');
ui.loadMore.classList.remove('hidden');
var span = ui.loadMore.querySelector('span');
if (span) span.textContent = 'Failed to load — click to retry';
ui.loadMore.onclick = function () {
ui.loadMore.onclick = null;
if (span) span.textContent = 'Fetching more wallpapers…';
fetchNextPage();
};
}
});
}
// Checks if the user is within 3 slides of the end and triggers a prefetch.
// Called after each slide change to ensure smooth infinite scrolling.
function maybeLoadMore() {
const images = getImages();
if (images.length === 0) return;
if (current >= images.length - 3 && !reachedBottom) {
fetchNextPage();
}
}
// ── Navigation ───────────────────────────────────────────────────────
// Moves forward (dir=1) or backward (dir=-1) by one slide.
// At the end of the collection: fetches more pages if available,
// otherwise wraps to the first slide. At the start: wraps to the last.
// Resets the autoplay timer if playing, so manual nav doesn't cause
// rapid double-advance.
function go(dir) {
const images = getImages();
if (images.length === 0) return;
let next = current + dir;
if (next >= images.length) {
if (!reachedBottom) {
fetchNextPage();
return;
}
next = 0;
} else if (next < 0) {
next = images.length - 1;
}
showSlide(next);
if (playing) scheduleAutoAdvance();
}
// Jumps directly to slide index i (used by thumbnail clicks).
function goTo(i) {
const images = getImages();
if (i >= 0 && i < images.length) {
showSlide(i);
if (playing) scheduleAutoAdvance();
}
}
// ── Display a slide by index ──────────────────────────────────────────
// Core function that manages slide transitions. Implements DOM virtualization:
// only the active slide and its neighbors (±SLIDE_WINDOW) are kept in the DOM.
// Slides outside this window are removed; missing slides within the window
// are created on-demand. This keeps memory usage low even with hundreds of
// wallpapers loaded.
//
// Image lazy loading: slides store the URL in data-src. When a slide becomes
// active, img.src is set from data-src, triggering the actual HTTP request.
// The spinner is shown until the image loads (or errors).
//
// Also updates: thumbnail strip active state, counter text, and triggers
// a prefetch if the user is near the end.
function showSlide(index) {
const images = getImages();
let needsLoading = false;
resetZoom();
// Remove slides outside the window
const existingSlides = ui.slides.querySelectorAll('.slide');
const windowStart = Math.max(0, index - SLIDE_WINDOW);
const windowEnd = Math.min(images.length - 1, index + SLIDE_WINDOW);
existingSlides.forEach(function (s) {
const slideIndex = parseInt(s.dataset.index, 10);
if (slideIndex < windowStart || slideIndex > windowEnd) {
s.remove();
}
});
// Create slides in the window that don't exist
for (let i = windowStart; i <= windowEnd; i++) {
let slide = ui.slides.querySelector('.slide[data-index="' + i + '"]');
if (!slide) {
slide = makeSlide(images[i], i);
slide.dataset.index = i;
ui.slides.appendChild(slide);
}
const isActive = i === index;
slide.classList.toggle('active', isActive);
if (isActive) {
const img = slide.querySelector('img');
if (img && !img.src) {
needsLoading = true;
ui.spinner.classList.remove('hidden');
img.addEventListener('load', function () {
updateBaseZoom();
clampPan();
applyZoom();
ui.spinner.classList.add('hidden');
}, { once: true });
img.addEventListener('error', function () {
ui.spinner.classList.add('hidden');
}, { once: true });
img.src = img.dataset.src;
} else if (img && img.complete) {
updateBaseZoom();
clampPan();
applyZoom();
}
}
}
if (!needsLoading) ui.spinner.classList.add('hidden');
const thumbs = ui.thumbs.querySelectorAll('.thumb');
thumbs.forEach(function (t, i) { t.classList.toggle('active', i === index); });
current = index;
ui.counter.textContent = (index + 1) + ' / ' + images.length;
if (thumbs[index]) {
thumbs[index].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
maybeLoadMore();
}
// ── Open the slideshow overlay ────────────────────────────────────────
// Initializes and shows the fullscreen slideshow:
// 1. Creates the slideshow DOM (Shadow DOM) on first call
// 2. Resets API pagination state for a fresh session
// 3. Builds the API base URL from the current page context
// 4. Creates all thumbnails (slides are created on-demand by showSlide)
// 5. Shows the overlay with a fade-in transition
// 6. Attaches the keyboard listener
function openSlideshow() {
const images = getImages();
if (images.length === 0) return;
if (!host) createSlideshow();
reachedBottom = false;
isFetching = false;
apiPage = 1;
apiLastPage = null;
apiSeed = null;
apiBaseUrl = buildApiBaseUrl();
if (!apiBaseUrl) reachedBottom = true;
ui.slides.innerHTML = '';
ui.thumbs.innerHTML = '';
ui.title.textContent = document.title.split('|')[0].trim() || 'Wallhaven Slideshow';
images.forEach(function (img, i) {
ui.thumbs.appendChild(makeThumb(img, i));
});
slideCount = images.length;
host.style.display = 'block';
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', onKey, true);
requestAnimationFrame(function () {
ui.root.classList.add('visible');
});
current = 0;
showSlide(0);
flashUI();
}
// ── Close the slideshow overlay ───────────────────────────────────────
// Cleans up and hides the slideshow:
// 1. Stops autoplay if active
// 2. Resets zoom/pan to defaults
// 3. Exits immersive mode if active
// 4. Fades out the overlay, then hides it after the transition
// 5. Removes the keyboard listener so it doesn't capture keys when closed
function closeSlideshow() {
if (playing) togglePlay();
isFetching = false;
resetZoom();
if (immersive) toggleImmersive();
ui.root.classList.remove('visible');
document.body.style.overflow = '';
document.removeEventListener('keydown', onKey, true);
setTimeout(function () {
if (host) host.style.display = 'none';
}, 300);
}
// ── Autoplay ─────────────────────────────────────────────────────────
// Schedules the next automatic slide advance using setTimeout (not setInterval).
// This is important: if the user manually navigates, the timer is reset,
// preventing rapid double-advances. The interval is 6500ms (6.5 seconds).
//
// At the end of the collection: fetches more pages if available,
// otherwise loops back to the first slide.
function scheduleAutoAdvance() {
clearTimeout(playTimer);
playTimer = setTimeout(function () {
if (!playing) return;
const images = getImages();
if (current < images.length - 1) {
go(1);
} else if (!reachedBottom) {
fetchNextPage();
} else {
showSlide(0);
}
}, 6500);
}
// Toggles autoplay on/off. Updates the play button icon and starts/stops
// the auto-advance timer.
function togglePlay() {
playing = !playing;
ui.play.textContent = playing ? '❚❚' : '▶';
if (playing) {
scheduleAutoAdvance();
} else {
clearTimeout(playTimer);
}
}
// ── Fullscreen ───────────────────────────────────────────────────────
// Toggles browser fullscreen mode on the slideshow host element.
// Uses the standard Fullscreen API with webkit prefix fallback for Safari.
// Silently catches errors (e.g. if fullscreen is blocked by browser policy).
function toggleFullscreen() {
const isFs = document.fullscreenElement || document.webkitFullscreenElement;
if (!isFs) {
const fn = host.requestFullscreen || host.webkitRequestFullscreen;
if (fn) {
try {
const r = fn.call(host);
if (r && r.catch) r.catch(function () {});
} catch (e) {}
}
} else {
const fn = document.exitFullscreen || document.webkitExitFullscreen;
if (fn) fn.call(document);
}
}
// ── Immersive mode ───────────────────────────────────────────────────
// Toggles "clean" mode that hides all UI (topbar, thumbnails, nav arrows)
// to show only the wallpaper. The exit hint remains visible so the user
// knows how to toggle it back (press C or click the hint).
// CSS handles the actual hiding via #root.immersive selectors.
function toggleImmersive() {
immersive = !immersive;
ui.root.classList.toggle('immersive', immersive);
ui.immersive.textContent = immersive ? '⬛' : '⬜';
if (immersive) {
ui.topbar.classList.add('hidden');
ui.thumbs.classList.add('hidden');
}
flashUI();
}
// ── Zoom and pan ─────────────────────────────────────────────────────
// The zoom system uses two layers:
// baseZoom — automatically computed "cover" scale that fills the viewport.
// This is the minimum scale where the image covers the entire
// screen (may crop edges for non-matching aspect ratios).
// zoom — user-controlled multiplier on top of baseZoom.
// 1 = fill viewport, 2 = 2x zoom, etc.
//
// The actual CSS transform is: translate(panX, panY) scale(baseZoom * zoom)
//
// Pan (panX/panY) is the pixel offset from center, clamped so the image
// can't be dragged out of viewport bounds.
// Returns the <img> element of the currently active slide, or null.
function getActiveImg() {
if (!ui.slides) return null;
return ui.slides.querySelector('.slide.active img');
}
// Computes the displayed pixel size of an image at a given total scale.
// Uses naturalWidth/Height (intrinsic image dimensions) as the base.
function getDisplayedSize(img, totalScale) {
const naturalW = img.naturalWidth || img.offsetWidth || 1;
const naturalH = img.naturalHeight || img.offsetHeight || 1;
return {
w: naturalW * totalScale,
h: naturalH * totalScale
};
}
// Computes the "cover" zoom level: the minimum scale where the image
// fully covers the viewport. This is the default view — the user's zoom
// multiplier (zoom=1) means "fill the screen, crop if needed".
function updateBaseZoom() {
const img = getActiveImg();
if (!img || !img.naturalWidth || !img.naturalHeight) return;
const scaleX = window.innerWidth / img.naturalWidth;
const scaleY = window.innerHeight / img.naturalHeight;
// cover = minimum scale that fully covers the viewport
baseZoom = Math.max(scaleX, scaleY);
}
// Resets all zoom/pan state to defaults and recomputes baseZoom.
// Called when changing slides to ensure each slide starts at the default view.
function resetZoom() {
zoom = 1;
panX = 0;
panY = 0;
baseZoom = 1;
const img = getActiveImg();
if (img) {
img.classList.remove('zoomed', 'dragging');
if (img.complete && img.naturalWidth) {
updateBaseZoom();
clampPan();
applyZoom();
} else {
img.style.transform = '';
}
}
if (ui.zoomBadge) ui.zoomBadge.classList.remove('visible');
}
// Applies the current zoom/pan state to the active image's CSS transform.
// Also updates the zoom badge display and the "zoomed" CSS class
// (which changes the cursor to "grab" when zoomed in).
function applyZoom() {
const img = getActiveImg();
if (!img) return;
const totalZoom = baseZoom * zoom;
img.style.transform =
'translate(' + panX + 'px,' + panY + 'px) scale(' + totalZoom + ')';
if (zoom > 1.001) img.classList.add('zoomed');
else img.classList.remove('zoomed');
if (ui.zoomBadge) {
let badge = Math.round(zoom * 100) + '%';
ui.zoomBadge.textContent = badge;
ui.zoomBadge.classList.add('visible');
ui.zoomBadge.classList.remove('hidden');
}
}
// Clamps panX/panY so the image stays within viewport bounds.
// The maximum allowed offset is half the difference between the displayed
// image size and the viewport size (i.e. the image edge aligns with the
// viewport edge). If the image is smaller than the viewport, pan is zeroed.
function clampPan() {
const img = getActiveImg();
if (!img) {
panX = 0;
panY = 0;
return;
}
const totalZoom = baseZoom * zoom;
let size = getDisplayedSize(img, totalZoom);
let w = size.w;
let h = size.h;
const maxX = Math.max(0, (w - window.innerWidth) / 2);
const maxY = Math.max(0, (h - window.innerHeight) / 2);
panX = Math.max(-maxX, Math.min(maxX, panX));
panY = Math.max(-maxY, Math.min(maxY, panY));
}
// Zooms toward a specific point (the cursor position).
// The zoom factor multiplies the current zoom level (e.g. 1.15 = 15% zoom in).
// The pan is adjusted so the point under the cursor stays in place — this
// creates the natural "zoom toward cursor" behavior.
//
// Math: the cursor's offset from the image center is scaled by the zoom ratio
// change, keeping the world-space point under the cursor fixed.
function zoomAtPoint(clientX, clientY, factor) {
const img = getActiveImg();
if (!img || !img.naturalWidth) return;
const rect = img.getBoundingClientRect();
const origCenterX = rect.left + rect.width / 2 - panX;
const origCenterY = rect.top + rect.height / 2 - panY;
const mx = clientX - origCenterX;
const my = clientY - origCenterY;
const oldZoom = zoom;
zoom = Math.max(0.1, Math.min(10, zoom * factor));
if (zoom === oldZoom) return;
const ratio = zoom / oldZoom;
panX = mx * (1 - ratio) + panX * ratio;
panY = my * (1 - ratio) + panY * ratio;
clampPan();
applyZoom();
}
// ── Mouse wheel zoom ──────────────────────────────────────────────────
// Scroll up = zoom in (factor 1.15), scroll down = zoom out (factor 1/1.15).
// Zooms toward the cursor position for natural feel.
function onWheel(e) {
if (!host || host.style.display === 'none') return;
e.preventDefault();
e.stopPropagation();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
zoomAtPoint(e.clientX, e.clientY, factor);
flashUI();
}
// Double-click toggles between default zoom and 2.5x zoom at the click point.
function onDblClick(e) {
if (!host || host.style.display === 'none') return;
e.preventDefault();
e.stopPropagation();
if (zoom > 1.001) {
resetZoom();
} else {
zoomAtPoint(e.clientX, e.clientY, 2.5);
}
flashUI();
}
// ── Mouse drag to pan ────────────────────────────────────────────────
// Only active when zoomed in (zoom > 1.001). Tracks the mouse from drag
// start, computes the delta, and applies it to panX/panY.
// The "dragging" class disables the CSS transition for real-time feedback.
function onDragStart(e) {
if (!host || host.style.display === 'none') return;
if (zoom <= 1.001) return;
const img = getActiveImg();
if (!img || !img.contains(e.target)) return;
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragStartPanX = panX;
dragStartPanY = panY;
img.classList.add('dragging');
e.preventDefault();
}
function onDragMove(e) {
if (!isDragging) return;
panX = dragStartPanX + (e.clientX - dragStartX);
panY = dragStartPanY + (e.clientY - dragStartY);
clampPan();
applyZoom();
}
function onDragEnd() {
if (!isDragging) return;
isDragging = false;
const img = getActiveImg();
if (img) img.classList.remove('dragging');
}
// ── Touch swipe for mobile navigation ────────────────────────────────
// Detects single-finger swipes for slide navigation on touch devices.
// Horizontal swipe > 50px (within 500ms) → navigate prev/next
// Vertical swipe down > 100px (within 500ms) → close slideshow
// Ignores slow gestures (> 500ms) and multi-touch to avoid conflicts
// with pinch-to-zoom or other browser gestures.
function onTouchStart(e) {
if (!host || host.style.display === 'none') return;
if (e.touches.length !== 1) return;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
}
function onTouchEnd(e) {
if (!host || host.style.display === 'none') return;
if (e.changedTouches.length !== 1) return;
const dx = e.changedTouches[0].clientX - touchStartX;
const dy = e.changedTouches[0].clientY - touchStartY;
const dt = Date.now() - touchStartTime;
if (dt > 500) return;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx > 50 && absDx > absDy) {
e.preventDefault();
go(dx < 0 ? 1 : -1);
flashUI();
} else if (dy > 100 && absDy > absDx) {
e.preventDefault();
closeSlideshow();
}
}
// ── UI auto-hide ─────────────────────────────────────────────────────
// Shows all UI elements (topbar, thumbnails, nav arrows, exit hint), then
// hides them after 3 seconds of inactivity. Called on mouse movement, clicks,
// keyboard input, and touch events.
//
// In immersive mode, only the exit hint is affected — the topbar and
// thumbnails stay hidden (controlled by CSS #root.immersive selectors).
function flashUI() {
if (!immersive) {
ui.topbar.classList.remove('hidden');
ui.thumbs.classList.remove('hidden');
}
ui.exitHint.classList.remove('hidden');
ui.prev.style.opacity = '1';
ui.next.style.opacity = '1';
clearTimeout(hideTimer);
hideTimer = setTimeout(function () {
ui.topbar.classList.add('hidden');
ui.thumbs.classList.add('hidden');
if (!immersive) ui.exitHint.classList.add('hidden');
ui.prev.style.opacity = '0';
ui.next.style.opacity = '0';
}, 3000);
}
// ── Keyboard shortcuts ────────────────────────────────────────────────
// Handles all keyboard input when the slideshow is open.
// ← / → Previous / Next slide
// ↑ / ↓ Zoom in / out (at viewport center)
// + / - Zoom in / out (alternative keys)
// Space Play / Pause autoplay
// F Toggle fullscreen
// C Toggle immersive mode
// R / 0 Reset zoom to default
// Esc Close slideshow
//
// All handlers call preventDefault/stopPropagation to prevent Wallhaven's
// own keyboard handlers from firing (e.g. arrow keys scrolling the page).
function onKey(e) {
if (!host || host.style.display === 'none') return;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault(); e.stopPropagation(); go(-1); flashUI(); break;
case 'ArrowRight':
e.preventDefault(); e.stopPropagation(); go(1); flashUI(); break;
case 'ArrowUp':
e.preventDefault(); e.stopPropagation();
zoomAtPoint(window.innerWidth / 2, window.innerHeight / 2, 1.3); flashUI(); break;
case 'ArrowDown':
e.preventDefault(); e.stopPropagation();
zoomAtPoint(window.innerWidth / 2, window.innerHeight / 2, 1 / 1.3); flashUI(); break;
case ' ':
e.preventDefault(); e.stopPropagation(); togglePlay(); flashUI(); break;
case 'f':
case 'F':
e.preventDefault(); toggleFullscreen(); break;
case 'c':
case 'C':
e.preventDefault(); e.stopPropagation(); toggleImmersive(); break;
case '0':
case 'r':
case 'R':
e.preventDefault(); e.stopPropagation(); resetZoom(); flashUI(); break;
case '+':
case '=':
e.preventDefault(); e.stopPropagation();
zoomAtPoint(window.innerWidth / 2, window.innerHeight / 2, 1.3); flashUI(); break;
case '-':
case '_':
e.preventDefault(); e.stopPropagation();
zoomAtPoint(window.innerWidth / 2, window.innerHeight / 2, 1 / 1.3); flashUI(); break;
case 'Escape':
e.preventDefault(); e.stopPropagation(); closeSlideshow(); break;
}
}
// ── SPA navigation detection ────────────────────────────────────────
// Wallhaven is a single-page application — URL changes don't trigger full
// page reloads. When the URL changes (e.g. user clicks a different section),
// we need to clear the collected wallpapers and rescan the new page.
//
// This function resets all state: clears the collection, closes the slideshow
// if open, and schedules a fresh scan after an 800ms delay (to let the SPA
// finish rendering new content).
function checkUrlChange() {
if (location.href !== lastUrl) {
lastUrl = location.href;
collected.clear();
cacheVersion++;
reachedBottom = false;
isFetching = false;
apiPage = 1;
apiLastPage = null;
apiSeed = null;
apiBaseUrl = null;
slideCount = 0;
if (host && host.style.display !== 'none') closeSlideshow();
clearTimeout(scanTimer);
scanTimer = setTimeout(function () {
scanImages();
startScanInterval();
}, 800);
}
}
// Sets up two mechanisms for detecting page changes:
//
// 1. MutationObserver: watches for new thumbnail elements being added to the
// DOM. This catches infinite scroll, lazy loading, and dynamic content.
// Uses debouncedScan to avoid rapid re-scans.
//
// 2. History API patching: overrides pushState and replaceState to detect
// SPA navigation. Also listens for the popstate event (browser back/forward).
// Each triggers checkUrlChange with a small delay to let the SPA settle.
function setupObserver() {
const observer = new MutationObserver(debouncedScan);
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'srcset', 'data-wallpaper-id'],
});
const origPush = history.pushState;
const origReplace = history.replaceState;
history.pushState = function () {
origPush.apply(this, arguments);
setTimeout(checkUrlChange, 100);
};
history.replaceState = function () {
origReplace.apply(this, arguments);
setTimeout(checkUrlChange, 100);
};
window.addEventListener('popstate', checkUrlChange);
}
// ── Init ─────────────────────────────────────────────────────────────
// Entry point. Creates the launch button, performs an initial scan of
// thumbnails on the page, and sets up the MutationObserver and History
// API patching for SPA navigation detection.
//
// Runs immediately if document.body exists (normal case with @run-at document-idle),
// otherwise waits for DOMContentLoaded (safety fallback).
function init() {
createButton();
scanImages();
setupObserver();
startScanInterval();
}
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();