Wallhaven Slideshow

Fullscreen image slideshow for Wallhaven with scale-to-fill default zoom

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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
// ==/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);
  }
})();