Raw Source
lain93 / Wallhaven Slideshow

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